diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index e3f6a74856..85409a7934 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -424,6 +424,9 @@ Class | Method | HTTP request | Description
  - [SyncAckDeleteDto](doc//SyncAckDeleteDto.md)
  - [SyncAckDto](doc//SyncAckDto.md)
  - [SyncAckSetDto](doc//SyncAckSetDto.md)
+ - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
+ - [SyncAssetExifV1](doc//SyncAssetExifV1.md)
+ - [SyncAssetV1](doc//SyncAssetV1.md)
  - [SyncEntityType](doc//SyncEntityType.md)
  - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md)
  - [SyncPartnerV1](doc//SyncPartnerV1.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 04dc43f88c..eddd63a732 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -231,6 +231,9 @@ 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_asset_delete_v1.dart';
+part 'model/sync_asset_exif_v1.dart';
+part 'model/sync_asset_v1.dart';
 part 'model/sync_entity_type.dart';
 part 'model/sync_partner_delete_v1.dart';
 part 'model/sync_partner_v1.dart';
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 4d837ccb9d..783e5e375e 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -518,6 +518,12 @@ class ApiClient {
           return SyncAckDto.fromJson(value);
         case 'SyncAckSetDto':
           return SyncAckSetDto.fromJson(value);
+        case 'SyncAssetDeleteV1':
+          return SyncAssetDeleteV1.fromJson(value);
+        case 'SyncAssetExifV1':
+          return SyncAssetExifV1.fromJson(value);
+        case 'SyncAssetV1':
+          return SyncAssetV1.fromJson(value);
         case 'SyncEntityType':
           return SyncEntityTypeTypeTransformer().decode(value);
         case 'SyncPartnerDeleteV1':
diff --git a/mobile/openapi/lib/model/sync_asset_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_delete_v1.dart
new file mode 100644
index 0000000000..c1787caf04
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_asset_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 SyncAssetDeleteV1 {
+  /// Returns a new [SyncAssetDeleteV1] instance.
+  SyncAssetDeleteV1({
+    required this.assetId,
+  });
+
+  String assetId;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SyncAssetDeleteV1 &&
+    other.assetId == assetId;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (assetId.hashCode);
+
+  @override
+  String toString() => 'SyncAssetDeleteV1[assetId=$assetId]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'assetId'] = this.assetId;
+    return json;
+  }
+
+  /// Returns a new [SyncAssetDeleteV1] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SyncAssetDeleteV1? fromJson(dynamic value) {
+    upgradeDto(value, "SyncAssetDeleteV1");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return SyncAssetDeleteV1(
+        assetId: mapValueOfType<String>(json, r'assetId')!,
+      );
+    }
+    return null;
+  }
+
+  static List<SyncAssetDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SyncAssetDeleteV1>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SyncAssetDeleteV1.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SyncAssetDeleteV1> mapFromJson(dynamic json) {
+    final map = <String, SyncAssetDeleteV1>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SyncAssetDeleteV1.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SyncAssetDeleteV1-objects as value to a dart map
+  static Map<String, List<SyncAssetDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SyncAssetDeleteV1>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = SyncAssetDeleteV1.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'assetId',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/sync_asset_exif_v1.dart b/mobile/openapi/lib/model/sync_asset_exif_v1.dart
new file mode 100644
index 0000000000..b0fef28b76
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_asset_exif_v1.dart
@@ -0,0 +1,387 @@
+//
+// 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 SyncAssetExifV1 {
+  /// Returns a new [SyncAssetExifV1] instance.
+  SyncAssetExifV1({
+    required this.assetId,
+    required this.city,
+    required this.country,
+    required this.dateTimeOriginal,
+    required this.description,
+    required this.exifImageHeight,
+    required this.exifImageWidth,
+    required this.exposureTime,
+    required this.fNumber,
+    required this.fileSizeInByte,
+    required this.focalLength,
+    required this.fps,
+    required this.iso,
+    required this.latitude,
+    required this.lensModel,
+    required this.longitude,
+    required this.make,
+    required this.model,
+    required this.modifyDate,
+    required this.orientation,
+    required this.profileDescription,
+    required this.projectionType,
+    required this.rating,
+    required this.state,
+    required this.timeZone,
+  });
+
+  String assetId;
+
+  String? city;
+
+  String? country;
+
+  DateTime? dateTimeOriginal;
+
+  String? description;
+
+  int? exifImageHeight;
+
+  int? exifImageWidth;
+
+  String? exposureTime;
+
+  int? fNumber;
+
+  int? fileSizeInByte;
+
+  int? focalLength;
+
+  int? fps;
+
+  int? iso;
+
+  int? latitude;
+
+  String? lensModel;
+
+  int? longitude;
+
+  String? make;
+
+  String? model;
+
+  DateTime? modifyDate;
+
+  String? orientation;
+
+  String? profileDescription;
+
+  String? projectionType;
+
+  int? rating;
+
+  String? state;
+
+  String? timeZone;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SyncAssetExifV1 &&
+    other.assetId == assetId &&
+    other.city == city &&
+    other.country == country &&
+    other.dateTimeOriginal == dateTimeOriginal &&
+    other.description == description &&
+    other.exifImageHeight == exifImageHeight &&
+    other.exifImageWidth == exifImageWidth &&
+    other.exposureTime == exposureTime &&
+    other.fNumber == fNumber &&
+    other.fileSizeInByte == fileSizeInByte &&
+    other.focalLength == focalLength &&
+    other.fps == fps &&
+    other.iso == iso &&
+    other.latitude == latitude &&
+    other.lensModel == lensModel &&
+    other.longitude == longitude &&
+    other.make == make &&
+    other.model == model &&
+    other.modifyDate == modifyDate &&
+    other.orientation == orientation &&
+    other.profileDescription == profileDescription &&
+    other.projectionType == projectionType &&
+    other.rating == rating &&
+    other.state == state &&
+    other.timeZone == timeZone;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (assetId.hashCode) +
+    (city == null ? 0 : city!.hashCode) +
+    (country == null ? 0 : country!.hashCode) +
+    (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
+    (description == null ? 0 : description!.hashCode) +
+    (exifImageHeight == null ? 0 : exifImageHeight!.hashCode) +
+    (exifImageWidth == null ? 0 : exifImageWidth!.hashCode) +
+    (exposureTime == null ? 0 : exposureTime!.hashCode) +
+    (fNumber == null ? 0 : fNumber!.hashCode) +
+    (fileSizeInByte == null ? 0 : fileSizeInByte!.hashCode) +
+    (focalLength == null ? 0 : focalLength!.hashCode) +
+    (fps == null ? 0 : fps!.hashCode) +
+    (iso == null ? 0 : iso!.hashCode) +
+    (latitude == null ? 0 : latitude!.hashCode) +
+    (lensModel == null ? 0 : lensModel!.hashCode) +
+    (longitude == null ? 0 : longitude!.hashCode) +
+    (make == null ? 0 : make!.hashCode) +
+    (model == null ? 0 : model!.hashCode) +
+    (modifyDate == null ? 0 : modifyDate!.hashCode) +
+    (orientation == null ? 0 : orientation!.hashCode) +
+    (profileDescription == null ? 0 : profileDescription!.hashCode) +
+    (projectionType == null ? 0 : projectionType!.hashCode) +
+    (rating == null ? 0 : rating!.hashCode) +
+    (state == null ? 0 : state!.hashCode) +
+    (timeZone == null ? 0 : timeZone!.hashCode);
+
+  @override
+  String toString() => 'SyncAssetExifV1[assetId=$assetId, city=$city, country=$country, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, fps=$fps, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, profileDescription=$profileDescription, projectionType=$projectionType, rating=$rating, state=$state, timeZone=$timeZone]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'assetId'] = this.assetId;
+    if (this.city != null) {
+      json[r'city'] = this.city;
+    } else {
+    //  json[r'city'] = null;
+    }
+    if (this.country != null) {
+      json[r'country'] = this.country;
+    } else {
+    //  json[r'country'] = null;
+    }
+    if (this.dateTimeOriginal != null) {
+      json[r'dateTimeOriginal'] = this.dateTimeOriginal!.toUtc().toIso8601String();
+    } else {
+    //  json[r'dateTimeOriginal'] = null;
+    }
+    if (this.description != null) {
+      json[r'description'] = this.description;
+    } else {
+    //  json[r'description'] = null;
+    }
+    if (this.exifImageHeight != null) {
+      json[r'exifImageHeight'] = this.exifImageHeight;
+    } else {
+    //  json[r'exifImageHeight'] = null;
+    }
+    if (this.exifImageWidth != null) {
+      json[r'exifImageWidth'] = this.exifImageWidth;
+    } else {
+    //  json[r'exifImageWidth'] = null;
+    }
+    if (this.exposureTime != null) {
+      json[r'exposureTime'] = this.exposureTime;
+    } else {
+    //  json[r'exposureTime'] = null;
+    }
+    if (this.fNumber != null) {
+      json[r'fNumber'] = this.fNumber;
+    } else {
+    //  json[r'fNumber'] = null;
+    }
+    if (this.fileSizeInByte != null) {
+      json[r'fileSizeInByte'] = this.fileSizeInByte;
+    } else {
+    //  json[r'fileSizeInByte'] = null;
+    }
+    if (this.focalLength != null) {
+      json[r'focalLength'] = this.focalLength;
+    } else {
+    //  json[r'focalLength'] = null;
+    }
+    if (this.fps != null) {
+      json[r'fps'] = this.fps;
+    } else {
+    //  json[r'fps'] = null;
+    }
+    if (this.iso != null) {
+      json[r'iso'] = this.iso;
+    } else {
+    //  json[r'iso'] = null;
+    }
+    if (this.latitude != null) {
+      json[r'latitude'] = this.latitude;
+    } else {
+    //  json[r'latitude'] = null;
+    }
+    if (this.lensModel != null) {
+      json[r'lensModel'] = this.lensModel;
+    } else {
+    //  json[r'lensModel'] = null;
+    }
+    if (this.longitude != null) {
+      json[r'longitude'] = this.longitude;
+    } else {
+    //  json[r'longitude'] = null;
+    }
+    if (this.make != null) {
+      json[r'make'] = this.make;
+    } else {
+    //  json[r'make'] = null;
+    }
+    if (this.model != null) {
+      json[r'model'] = this.model;
+    } else {
+    //  json[r'model'] = null;
+    }
+    if (this.modifyDate != null) {
+      json[r'modifyDate'] = this.modifyDate!.toUtc().toIso8601String();
+    } else {
+    //  json[r'modifyDate'] = null;
+    }
+    if (this.orientation != null) {
+      json[r'orientation'] = this.orientation;
+    } else {
+    //  json[r'orientation'] = null;
+    }
+    if (this.profileDescription != null) {
+      json[r'profileDescription'] = this.profileDescription;
+    } else {
+    //  json[r'profileDescription'] = null;
+    }
+    if (this.projectionType != null) {
+      json[r'projectionType'] = this.projectionType;
+    } else {
+    //  json[r'projectionType'] = null;
+    }
+    if (this.rating != null) {
+      json[r'rating'] = this.rating;
+    } else {
+    //  json[r'rating'] = null;
+    }
+    if (this.state != null) {
+      json[r'state'] = this.state;
+    } else {
+    //  json[r'state'] = null;
+    }
+    if (this.timeZone != null) {
+      json[r'timeZone'] = this.timeZone;
+    } else {
+    //  json[r'timeZone'] = null;
+    }
+    return json;
+  }
+
+  /// Returns a new [SyncAssetExifV1] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SyncAssetExifV1? fromJson(dynamic value) {
+    upgradeDto(value, "SyncAssetExifV1");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return SyncAssetExifV1(
+        assetId: mapValueOfType<String>(json, r'assetId')!,
+        city: mapValueOfType<String>(json, r'city'),
+        country: mapValueOfType<String>(json, r'country'),
+        dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
+        description: mapValueOfType<String>(json, r'description'),
+        exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'),
+        exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'),
+        exposureTime: mapValueOfType<String>(json, r'exposureTime'),
+        fNumber: mapValueOfType<int>(json, r'fNumber'),
+        fileSizeInByte: mapValueOfType<int>(json, r'fileSizeInByte'),
+        focalLength: mapValueOfType<int>(json, r'focalLength'),
+        fps: mapValueOfType<int>(json, r'fps'),
+        iso: mapValueOfType<int>(json, r'iso'),
+        latitude: mapValueOfType<int>(json, r'latitude'),
+        lensModel: mapValueOfType<String>(json, r'lensModel'),
+        longitude: mapValueOfType<int>(json, r'longitude'),
+        make: mapValueOfType<String>(json, r'make'),
+        model: mapValueOfType<String>(json, r'model'),
+        modifyDate: mapDateTime(json, r'modifyDate', r''),
+        orientation: mapValueOfType<String>(json, r'orientation'),
+        profileDescription: mapValueOfType<String>(json, r'profileDescription'),
+        projectionType: mapValueOfType<String>(json, r'projectionType'),
+        rating: mapValueOfType<int>(json, r'rating'),
+        state: mapValueOfType<String>(json, r'state'),
+        timeZone: mapValueOfType<String>(json, r'timeZone'),
+      );
+    }
+    return null;
+  }
+
+  static List<SyncAssetExifV1> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SyncAssetExifV1>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SyncAssetExifV1.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SyncAssetExifV1> mapFromJson(dynamic json) {
+    final map = <String, SyncAssetExifV1>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SyncAssetExifV1.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SyncAssetExifV1-objects as value to a dart map
+  static Map<String, List<SyncAssetExifV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SyncAssetExifV1>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = SyncAssetExifV1.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'assetId',
+    'city',
+    'country',
+    'dateTimeOriginal',
+    'description',
+    'exifImageHeight',
+    'exifImageWidth',
+    'exposureTime',
+    'fNumber',
+    'fileSizeInByte',
+    'focalLength',
+    'fps',
+    'iso',
+    'latitude',
+    'lensModel',
+    'longitude',
+    'make',
+    'model',
+    'modifyDate',
+    'orientation',
+    'profileDescription',
+    'projectionType',
+    'rating',
+    'state',
+    'timeZone',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart
new file mode 100644
index 0000000000..6f9d7d7eaf
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_asset_v1.dart
@@ -0,0 +1,279 @@
+//
+// 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 SyncAssetV1 {
+  /// Returns a new [SyncAssetV1] instance.
+  SyncAssetV1({
+    required this.checksum,
+    required this.deletedAt,
+    required this.fileCreatedAt,
+    required this.fileModifiedAt,
+    required this.id,
+    required this.isFavorite,
+    required this.isVisible,
+    required this.localDateTime,
+    required this.ownerId,
+    required this.thumbhash,
+    required this.type,
+  });
+
+  String checksum;
+
+  DateTime? deletedAt;
+
+  DateTime? fileCreatedAt;
+
+  DateTime? fileModifiedAt;
+
+  String id;
+
+  bool isFavorite;
+
+  bool isVisible;
+
+  DateTime? localDateTime;
+
+  String ownerId;
+
+  String? thumbhash;
+
+  SyncAssetV1TypeEnum type;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 &&
+    other.checksum == checksum &&
+    other.deletedAt == deletedAt &&
+    other.fileCreatedAt == fileCreatedAt &&
+    other.fileModifiedAt == fileModifiedAt &&
+    other.id == id &&
+    other.isFavorite == isFavorite &&
+    other.isVisible == isVisible &&
+    other.localDateTime == localDateTime &&
+    other.ownerId == ownerId &&
+    other.thumbhash == thumbhash &&
+    other.type == type;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (checksum.hashCode) +
+    (deletedAt == null ? 0 : deletedAt!.hashCode) +
+    (fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
+    (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
+    (id.hashCode) +
+    (isFavorite.hashCode) +
+    (isVisible.hashCode) +
+    (localDateTime == null ? 0 : localDateTime!.hashCode) +
+    (ownerId.hashCode) +
+    (thumbhash == null ? 0 : thumbhash!.hashCode) +
+    (type.hashCode);
+
+  @override
+  String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, isVisible=$isVisible, localDateTime=$localDateTime, ownerId=$ownerId, thumbhash=$thumbhash, type=$type]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'checksum'] = this.checksum;
+    if (this.deletedAt != null) {
+      json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
+    } else {
+    //  json[r'deletedAt'] = null;
+    }
+    if (this.fileCreatedAt != null) {
+      json[r'fileCreatedAt'] = this.fileCreatedAt!.toUtc().toIso8601String();
+    } else {
+    //  json[r'fileCreatedAt'] = null;
+    }
+    if (this.fileModifiedAt != null) {
+      json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String();
+    } else {
+    //  json[r'fileModifiedAt'] = null;
+    }
+      json[r'id'] = this.id;
+      json[r'isFavorite'] = this.isFavorite;
+      json[r'isVisible'] = this.isVisible;
+    if (this.localDateTime != null) {
+      json[r'localDateTime'] = this.localDateTime!.toUtc().toIso8601String();
+    } else {
+    //  json[r'localDateTime'] = null;
+    }
+      json[r'ownerId'] = this.ownerId;
+    if (this.thumbhash != null) {
+      json[r'thumbhash'] = this.thumbhash;
+    } else {
+    //  json[r'thumbhash'] = null;
+    }
+      json[r'type'] = this.type;
+    return json;
+  }
+
+  /// Returns a new [SyncAssetV1] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SyncAssetV1? fromJson(dynamic value) {
+    upgradeDto(value, "SyncAssetV1");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return SyncAssetV1(
+        checksum: mapValueOfType<String>(json, r'checksum')!,
+        deletedAt: mapDateTime(json, r'deletedAt', r''),
+        fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''),
+        fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''),
+        id: mapValueOfType<String>(json, r'id')!,
+        isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
+        isVisible: mapValueOfType<bool>(json, r'isVisible')!,
+        localDateTime: mapDateTime(json, r'localDateTime', r''),
+        ownerId: mapValueOfType<String>(json, r'ownerId')!,
+        thumbhash: mapValueOfType<String>(json, r'thumbhash'),
+        type: SyncAssetV1TypeEnum.fromJson(json[r'type'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<SyncAssetV1> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SyncAssetV1>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SyncAssetV1.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SyncAssetV1> mapFromJson(dynamic json) {
+    final map = <String, SyncAssetV1>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SyncAssetV1.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SyncAssetV1-objects as value to a dart map
+  static Map<String, List<SyncAssetV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SyncAssetV1>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = SyncAssetV1.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'checksum',
+    'deletedAt',
+    'fileCreatedAt',
+    'fileModifiedAt',
+    'id',
+    'isFavorite',
+    'isVisible',
+    'localDateTime',
+    'ownerId',
+    'thumbhash',
+    'type',
+  };
+}
+
+
+class SyncAssetV1TypeEnum {
+  /// Instantiate a new enum with the provided [value].
+  const SyncAssetV1TypeEnum._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const IMAGE = SyncAssetV1TypeEnum._(r'IMAGE');
+  static const VIDEO = SyncAssetV1TypeEnum._(r'VIDEO');
+  static const AUDIO = SyncAssetV1TypeEnum._(r'AUDIO');
+  static const OTHER = SyncAssetV1TypeEnum._(r'OTHER');
+
+  /// List of all possible values in this [enum][SyncAssetV1TypeEnum].
+  static const values = <SyncAssetV1TypeEnum>[
+    IMAGE,
+    VIDEO,
+    AUDIO,
+    OTHER,
+  ];
+
+  static SyncAssetV1TypeEnum? fromJson(dynamic value) => SyncAssetV1TypeEnumTypeTransformer().decode(value);
+
+  static List<SyncAssetV1TypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SyncAssetV1TypeEnum>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SyncAssetV1TypeEnum.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [SyncAssetV1TypeEnum] to String,
+/// and [decode] dynamic data back to [SyncAssetV1TypeEnum].
+class SyncAssetV1TypeEnumTypeTransformer {
+  factory SyncAssetV1TypeEnumTypeTransformer() => _instance ??= const SyncAssetV1TypeEnumTypeTransformer._();
+
+  const SyncAssetV1TypeEnumTypeTransformer._();
+
+  String encode(SyncAssetV1TypeEnum data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a SyncAssetV1TypeEnum.
+  ///
+  /// 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.
+  SyncAssetV1TypeEnum? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'IMAGE': return SyncAssetV1TypeEnum.IMAGE;
+        case r'VIDEO': return SyncAssetV1TypeEnum.VIDEO;
+        case r'AUDIO': return SyncAssetV1TypeEnum.AUDIO;
+        case r'OTHER': return SyncAssetV1TypeEnum.OTHER;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [SyncAssetV1TypeEnumTypeTransformer] instance.
+  static SyncAssetV1TypeEnumTypeTransformer? _instance;
+}
+
+
diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart
index 5d130f7f93..5e52a10e7a 100644
--- a/mobile/openapi/lib/model/sync_entity_type.dart
+++ b/mobile/openapi/lib/model/sync_entity_type.dart
@@ -27,6 +27,12 @@ class SyncEntityType {
   static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1');
   static const partnerV1 = SyncEntityType._(r'PartnerV1');
   static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1');
+  static const assetV1 = SyncEntityType._(r'AssetV1');
+  static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
+  static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
+  static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1');
+  static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1');
+  static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1');
 
   /// List of all possible values in this [enum][SyncEntityType].
   static const values = <SyncEntityType>[
@@ -34,6 +40,12 @@ class SyncEntityType {
     userDeleteV1,
     partnerV1,
     partnerDeleteV1,
+    assetV1,
+    assetDeleteV1,
+    assetExifV1,
+    partnerAssetV1,
+    partnerAssetDeleteV1,
+    partnerAssetExifV1,
   ];
 
   static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value);
@@ -76,6 +88,12 @@ class SyncEntityTypeTypeTransformer {
         case r'UserDeleteV1': return SyncEntityType.userDeleteV1;
         case r'PartnerV1': return SyncEntityType.partnerV1;
         case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1;
+        case r'AssetV1': return SyncEntityType.assetV1;
+        case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
+        case r'AssetExifV1': return SyncEntityType.assetExifV1;
+        case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1;
+        case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1;
+        case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1;
         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 c35b17dea1..08f977ad57 100644
--- a/mobile/openapi/lib/model/sync_request_type.dart
+++ b/mobile/openapi/lib/model/sync_request_type.dart
@@ -25,11 +25,19 @@ class SyncRequestType {
 
   static const usersV1 = SyncRequestType._(r'UsersV1');
   static const partnersV1 = SyncRequestType._(r'PartnersV1');
+  static const assetsV1 = SyncRequestType._(r'AssetsV1');
+  static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
+  static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1');
+  static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1');
 
   /// List of all possible values in this [enum][SyncRequestType].
   static const values = <SyncRequestType>[
     usersV1,
     partnersV1,
+    assetsV1,
+    assetExifsV1,
+    partnerAssetsV1,
+    partnerAssetExifsV1,
   ];
 
   static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value);
@@ -70,6 +78,10 @@ class SyncRequestTypeTypeTransformer {
       switch (data) {
         case r'UsersV1': return SyncRequestType.usersV1;
         case r'PartnersV1': return SyncRequestType.partnersV1;
+        case r'AssetsV1': return SyncRequestType.assetsV1;
+        case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
+        case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1;
+        case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1;
         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 c1921da82d..d503e565df 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -12049,12 +12049,228 @@
         ],
         "type": "object"
       },
+      "SyncAssetDeleteV1": {
+        "properties": {
+          "assetId": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "assetId"
+        ],
+        "type": "object"
+      },
+      "SyncAssetExifV1": {
+        "properties": {
+          "assetId": {
+            "type": "string"
+          },
+          "city": {
+            "nullable": true,
+            "type": "string"
+          },
+          "country": {
+            "nullable": true,
+            "type": "string"
+          },
+          "dateTimeOriginal": {
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          },
+          "description": {
+            "nullable": true,
+            "type": "string"
+          },
+          "exifImageHeight": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "exifImageWidth": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "exposureTime": {
+            "nullable": true,
+            "type": "string"
+          },
+          "fNumber": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "fileSizeInByte": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "focalLength": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "fps": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "iso": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "latitude": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "lensModel": {
+            "nullable": true,
+            "type": "string"
+          },
+          "longitude": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "make": {
+            "nullable": true,
+            "type": "string"
+          },
+          "model": {
+            "nullable": true,
+            "type": "string"
+          },
+          "modifyDate": {
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          },
+          "orientation": {
+            "nullable": true,
+            "type": "string"
+          },
+          "profileDescription": {
+            "nullable": true,
+            "type": "string"
+          },
+          "projectionType": {
+            "nullable": true,
+            "type": "string"
+          },
+          "rating": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "state": {
+            "nullable": true,
+            "type": "string"
+          },
+          "timeZone": {
+            "nullable": true,
+            "type": "string"
+          }
+        },
+        "required": [
+          "assetId",
+          "city",
+          "country",
+          "dateTimeOriginal",
+          "description",
+          "exifImageHeight",
+          "exifImageWidth",
+          "exposureTime",
+          "fNumber",
+          "fileSizeInByte",
+          "focalLength",
+          "fps",
+          "iso",
+          "latitude",
+          "lensModel",
+          "longitude",
+          "make",
+          "model",
+          "modifyDate",
+          "orientation",
+          "profileDescription",
+          "projectionType",
+          "rating",
+          "state",
+          "timeZone"
+        ],
+        "type": "object"
+      },
+      "SyncAssetV1": {
+        "properties": {
+          "checksum": {
+            "type": "string"
+          },
+          "deletedAt": {
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          },
+          "fileCreatedAt": {
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          },
+          "fileModifiedAt": {
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          },
+          "id": {
+            "type": "string"
+          },
+          "isFavorite": {
+            "type": "boolean"
+          },
+          "isVisible": {
+            "type": "boolean"
+          },
+          "localDateTime": {
+            "format": "date-time",
+            "nullable": true,
+            "type": "string"
+          },
+          "ownerId": {
+            "type": "string"
+          },
+          "thumbhash": {
+            "nullable": true,
+            "type": "string"
+          },
+          "type": {
+            "enum": [
+              "IMAGE",
+              "VIDEO",
+              "AUDIO",
+              "OTHER"
+            ],
+            "type": "string"
+          }
+        },
+        "required": [
+          "checksum",
+          "deletedAt",
+          "fileCreatedAt",
+          "fileModifiedAt",
+          "id",
+          "isFavorite",
+          "isVisible",
+          "localDateTime",
+          "ownerId",
+          "thumbhash",
+          "type"
+        ],
+        "type": "object"
+      },
       "SyncEntityType": {
         "enum": [
           "UserV1",
           "UserDeleteV1",
           "PartnerV1",
-          "PartnerDeleteV1"
+          "PartnerDeleteV1",
+          "AssetV1",
+          "AssetDeleteV1",
+          "AssetExifV1",
+          "PartnerAssetV1",
+          "PartnerAssetDeleteV1",
+          "PartnerAssetExifV1"
         ],
         "type": "string"
       },
@@ -12095,7 +12311,11 @@
       "SyncRequestType": {
         "enum": [
           "UsersV1",
-          "PartnersV1"
+          "PartnersV1",
+          "AssetsV1",
+          "AssetExifsV1",
+          "PartnerAssetsV1",
+          "PartnerAssetExifsV1"
         ],
         "type": "string"
       },
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index fb70a42e84..85f80eec99 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -3647,11 +3647,21 @@ export enum SyncEntityType {
     UserV1 = "UserV1",
     UserDeleteV1 = "UserDeleteV1",
     PartnerV1 = "PartnerV1",
-    PartnerDeleteV1 = "PartnerDeleteV1"
+    PartnerDeleteV1 = "PartnerDeleteV1",
+    AssetV1 = "AssetV1",
+    AssetDeleteV1 = "AssetDeleteV1",
+    AssetExifV1 = "AssetExifV1",
+    PartnerAssetV1 = "PartnerAssetV1",
+    PartnerAssetDeleteV1 = "PartnerAssetDeleteV1",
+    PartnerAssetExifV1 = "PartnerAssetExifV1"
 }
 export enum SyncRequestType {
     UsersV1 = "UsersV1",
-    PartnersV1 = "PartnersV1"
+    PartnersV1 = "PartnersV1",
+    AssetsV1 = "AssetsV1",
+    AssetExifsV1 = "AssetExifsV1",
+    PartnerAssetsV1 = "PartnerAssetsV1",
+    PartnerAssetExifsV1 = "PartnerAssetExifsV1"
 }
 export enum TranscodeHWAccel {
     Nvenc = "nvenc",
diff --git a/server/src/database.ts b/server/src/database.ts
index 08ed7240db..e899200579 100644
--- a/server/src/database.ts
+++ b/server/src/database.ts
@@ -117,4 +117,46 @@ export const columns = {
   userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'],
   tagDto: ['id', 'value', 'createdAt', 'updatedAt', 'color', 'parentId'],
   apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
+  syncAsset: [
+    'id',
+    'ownerId',
+    'thumbhash',
+    'checksum',
+    'fileCreatedAt',
+    'fileModifiedAt',
+    'localDateTime',
+    'type',
+    'deletedAt',
+    'isFavorite',
+    'isVisible',
+    'updateId',
+  ],
+  syncAssetExif: [
+    'exif.assetId',
+    'exif.description',
+    'exif.exifImageWidth',
+    'exif.exifImageHeight',
+    'exif.fileSizeInByte',
+    'exif.orientation',
+    'exif.dateTimeOriginal',
+    'exif.modifyDate',
+    'exif.timeZone',
+    'exif.latitude',
+    'exif.longitude',
+    'exif.projectionType',
+    'exif.city',
+    'exif.state',
+    'exif.country',
+    'exif.make',
+    'exif.model',
+    'exif.lensModel',
+    'exif.fNumber',
+    'exif.focalLength',
+    'exif.iso',
+    'exif.exposureTime',
+    'exif.profileDescription',
+    'exif.rating',
+    'exif.fps',
+    'exif.updateId',
+  ],
 } as const;
diff --git a/server/src/db.d.ts b/server/src/db.d.ts
index a27faac9b6..85aade2c9b 100644
--- a/server/src/db.d.ts
+++ b/server/src/db.d.ts
@@ -119,6 +119,13 @@ export interface AssetJobStatus {
   thumbnailAt: Timestamp | null;
 }
 
+export interface AssetsAudit {
+  deletedAt: Generated<Timestamp>;
+  id: Generated<string>;
+  assetId: string;
+  ownerId: string;
+}
+
 export interface Assets {
   checksum: Buffer;
   createdAt: Generated<Timestamp>;
@@ -168,6 +175,8 @@ export interface Audit {
 
 export interface Exif {
   assetId: string;
+  updateId: Generated<string>;
+  updatedAt: Generated<Timestamp>;
   autoStackId: string | null;
   bitsPerSample: number | null;
   city: string | null;
@@ -459,6 +468,7 @@ export interface DB {
   asset_job_status: AssetJobStatus;
   asset_stack: AssetStack;
   assets: Assets;
+  assets_audit: AssetsAudit;
   audit: Audit;
   exif: Exif;
   face_search: FaceSearch;
diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts
index 9a963e1e98..b12a4378fe 100644
--- a/server/src/dtos/asset-response.dto.ts
+++ b/server/src/dtos/asset-response.dto.ts
@@ -102,7 +102,7 @@ const mapStack = (entity: AssetEntity) => {
 };
 
 // if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
-const hexOrBufferToBase64 = (encoded: string | Buffer) => {
+export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
   if (typeof encoded === 'string') {
     return Buffer.from(encoded.slice(2), 'hex').toString('base64');
   }
diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts
index d191c82bb3..a035f8ecb9 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 { SyncEntityType, SyncRequestType } from 'src/enum';
+import { AssetType, SyncEntityType, SyncRequestType } from 'src/enum';
 import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
 
 export class AssetFullSyncDto {
@@ -56,11 +56,73 @@ export class SyncPartnerDeleteV1 {
   sharedWithId!: string;
 }
 
+export class SyncAssetV1 {
+  id!: string;
+  ownerId!: string;
+  thumbhash!: string | null;
+  checksum!: string;
+  fileCreatedAt!: Date | null;
+  fileModifiedAt!: Date | null;
+  localDateTime!: Date | null;
+  type!: AssetType;
+  deletedAt!: Date | null;
+  isFavorite!: boolean;
+  isVisible!: boolean;
+}
+
+export class SyncAssetDeleteV1 {
+  assetId!: string;
+}
+
+export class SyncAssetExifV1 {
+  assetId!: string;
+  description!: string | null;
+  @ApiProperty({ type: 'integer' })
+  exifImageWidth!: number | null;
+  @ApiProperty({ type: 'integer' })
+  exifImageHeight!: number | null;
+  @ApiProperty({ type: 'integer' })
+  fileSizeInByte!: number | null;
+  orientation!: string | null;
+  dateTimeOriginal!: Date | null;
+  modifyDate!: Date | null;
+  timeZone!: string | null;
+  @ApiProperty({ type: 'integer' })
+  latitude!: number | null;
+  @ApiProperty({ type: 'integer' })
+  longitude!: number | null;
+  projectionType!: string | null;
+  city!: string | null;
+  state!: string | null;
+  country!: string | null;
+  make!: string | null;
+  model!: string | null;
+  lensModel!: string | null;
+  @ApiProperty({ type: 'integer' })
+  fNumber!: number | null;
+  @ApiProperty({ type: 'integer' })
+  focalLength!: number | null;
+  @ApiProperty({ type: 'integer' })
+  iso!: number | null;
+  exposureTime!: string | null;
+  profileDescription!: string | null;
+  @ApiProperty({ type: 'integer' })
+  rating!: number | null;
+  @ApiProperty({ type: 'integer' })
+  fps!: number | null;
+}
+
 export type SyncItem = {
   [SyncEntityType.UserV1]: SyncUserV1;
   [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
   [SyncEntityType.PartnerV1]: SyncPartnerV1;
   [SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
+  [SyncEntityType.AssetV1]: SyncAssetV1;
+  [SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
+  [SyncEntityType.AssetExifV1]: SyncAssetExifV1;
+  [SyncEntityType.PartnerAssetV1]: SyncAssetV1;
+  [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
+  [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
 };
 
 const responseDtos = [
@@ -69,6 +131,9 @@ const responseDtos = [
   SyncUserDeleteV1,
   SyncPartnerV1,
   SyncPartnerDeleteV1,
+  SyncAssetV1,
+  SyncAssetDeleteV1,
+  SyncAssetExifV1,
 ];
 
 export const extraSyncModels = responseDtos;
diff --git a/server/src/entities/asset-audit.entity.ts b/server/src/entities/asset-audit.entity.ts
new file mode 100644
index 0000000000..0172d15ce6
--- /dev/null
+++ b/server/src/entities/asset-audit.entity.ts
@@ -0,0 +1,19 @@
+import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
+
+@Entity('assets_audit')
+export class AssetAuditEntity {
+  @PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
+  id!: string;
+
+  @Index('IDX_assets_audit_asset_id')
+  @Column({ type: 'uuid' })
+  assetId!: string;
+
+  @Index('IDX_assets_audit_owner_id')
+  @Column({ type: 'uuid' })
+  ownerId!: string;
+
+  @Index('IDX_assets_audit_deleted_at')
+  @CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' })
+  deletedAt!: Date;
+}
diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts
index c9c29d732a..5b402109a5 100644
--- a/server/src/entities/exif.entity.ts
+++ b/server/src/entities/exif.entity.ts
@@ -1,5 +1,5 @@
 import { AssetEntity } from 'src/entities/asset.entity';
-import { Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
+import { Index, JoinColumn, OneToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm';
 import { Column } from 'typeorm/decorator/columns/Column.js';
 import { Entity } from 'typeorm/decorator/entity/Entity.js';
 
@@ -12,6 +12,13 @@ export class ExifEntity {
   @PrimaryColumn()
   assetId!: string;
 
+  @UpdateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' })
+  updatedAt?: Date;
+
+  @Index('IDX_asset_exif_update_id')
+  @Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
+  updateId?: string;
+
   /* General info */
   @Column({ type: 'text', default: '' })
   description!: string; // or caption
diff --git a/server/src/enum.ts b/server/src/enum.ts
index 47154e3258..55e435a70b 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -549,11 +549,24 @@ export enum DatabaseLock {
 export enum SyncRequestType {
   UsersV1 = 'UsersV1',
   PartnersV1 = 'PartnersV1',
+  AssetsV1 = 'AssetsV1',
+  AssetExifsV1 = 'AssetExifsV1',
+  PartnerAssetsV1 = 'PartnerAssetsV1',
+  PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
 }
 
 export enum SyncEntityType {
   UserV1 = 'UserV1',
   UserDeleteV1 = 'UserDeleteV1',
+
   PartnerV1 = 'PartnerV1',
   PartnerDeleteV1 = 'PartnerDeleteV1',
+
+  AssetV1 = 'AssetV1',
+  AssetDeleteV1 = 'AssetDeleteV1',
+  AssetExifV1 = 'AssetExifV1',
+
+  PartnerAssetV1 = 'PartnerAssetV1',
+  PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
+  PartnerAssetExifV1 = 'PartnerAssetExifV1',
 }
diff --git a/server/src/migrations/1741191762113-AssetAuditTable.ts b/server/src/migrations/1741191762113-AssetAuditTable.ts
new file mode 100644
index 0000000000..c02408c384
--- /dev/null
+++ b/server/src/migrations/1741191762113-AssetAuditTable.ts
@@ -0,0 +1,37 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AssetAuditTable1741191762113 implements MigrationInterface {
+    name = 'AssetAuditTable1741191762113'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`CREATE TABLE "assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "assetId" uuid NOT NULL, "ownerId" uuid NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp(), CONSTRAINT "PK_99bd5c015f81a641927a32b4212" PRIMARY KEY ("id"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_assets_audit_asset_id" ON "assets_audit" ("assetId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_assets_audit_owner_id" ON "assets_audit" ("ownerId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_assets_audit_deleted_at" ON "assets_audit" ("deletedAt") `);
+        await queryRunner.query(`CREATE OR REPLACE FUNCTION assets_delete_audit() RETURNS TRIGGER AS
+          $$
+           BEGIN
+            INSERT INTO assets_audit ("assetId", "ownerId")
+            SELECT "id", "ownerId"
+            FROM OLD;
+            RETURN NULL;
+           END;
+          $$ LANGUAGE plpgsql`
+        );
+        await queryRunner.query(`CREATE OR REPLACE TRIGGER assets_delete_audit
+           AFTER DELETE ON assets
+           REFERENCING OLD TABLE AS OLD
+           FOR EACH STATEMENT
+           EXECUTE FUNCTION assets_delete_audit();
+        `);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`DROP TRIGGER assets_delete_audit`);
+        await queryRunner.query(`DROP FUNCTION assets_delete_audit`);
+        await queryRunner.query(`DROP INDEX "IDX_assets_audit_deleted_at"`);
+        await queryRunner.query(`DROP INDEX "IDX_assets_audit_owner_id"`);
+        await queryRunner.query(`DROP INDEX "IDX_assets_audit_asset_id"`);
+        await queryRunner.query(`DROP TABLE "assets_audit"`);
+    }
+}
diff --git a/server/src/migrations/1741280328985-FixAssetAndUserCascadeConditions.ts b/server/src/migrations/1741280328985-FixAssetAndUserCascadeConditions.ts
new file mode 100644
index 0000000000..20215c1b59
--- /dev/null
+++ b/server/src/migrations/1741280328985-FixAssetAndUserCascadeConditions.ts
@@ -0,0 +1,50 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class FixAssetAndUserCascadeConditions1741280328985 implements MigrationInterface {
+  name = 'FixAssetAndUserCascadeConditions1741280328985';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+      CREATE OR REPLACE TRIGGER assets_delete_audit
+      AFTER DELETE ON assets
+      REFERENCING OLD TABLE AS OLD
+      FOR EACH STATEMENT
+      WHEN (pg_trigger_depth() = 0)
+      EXECUTE FUNCTION assets_delete_audit();`);
+    await queryRunner.query(`
+      CREATE OR REPLACE TRIGGER users_delete_audit
+      AFTER DELETE ON users
+      REFERENCING OLD TABLE AS OLD
+      FOR EACH STATEMENT
+      WHEN (pg_trigger_depth() = 0)
+      EXECUTE FUNCTION users_delete_audit();`);
+    await queryRunner.query(`
+      CREATE OR REPLACE TRIGGER partners_delete_audit
+      AFTER DELETE ON partners
+      REFERENCING OLD TABLE AS OLD
+      FOR EACH STATEMENT
+      WHEN (pg_trigger_depth() = 0)
+      EXECUTE FUNCTION partners_delete_audit();`);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+      CREATE OR REPLACE TRIGGER assets_delete_audit
+      AFTER DELETE ON assets
+      REFERENCING OLD TABLE AS OLD
+      FOR EACH STATEMENT
+      EXECUTE FUNCTION assets_delete_audit();`);
+    await queryRunner.query(`
+      CREATE OR REPLACE TRIGGER users_delete_audit
+      AFTER DELETE ON users
+      REFERENCING OLD TABLE AS OLD
+      FOR EACH STATEMENT
+      EXECUTE FUNCTION users_delete_audit();`);
+    await queryRunner.query(`
+      CREATE OR REPLACE TRIGGER partners_delete_audit
+      AFTER DELETE ON partners
+      REFERENCING OLD TABLE AS OLD
+      FOR EACH STATEMENT
+      EXECUTE FUNCTION partners_delete_audit();`);
+  }
+}
diff --git a/server/src/migrations/1741281344519-AddExifUpdateId.ts b/server/src/migrations/1741281344519-AddExifUpdateId.ts
new file mode 100644
index 0000000000..eb32836a1d
--- /dev/null
+++ b/server/src/migrations/1741281344519-AddExifUpdateId.ts
@@ -0,0 +1,25 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddExifUpdateId1741281344519 implements MigrationInterface {
+  name = 'AddExifUpdateId1741281344519';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(
+      `ALTER TABLE "exif" ADD "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp()`,
+    );
+    await queryRunner.query(`ALTER TABLE "exif" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7()`);
+    await queryRunner.query(`CREATE INDEX "IDX_asset_exif_update_id" ON "exif" ("updateId") `);
+    await queryRunner.query(`
+        create trigger asset_exif_updated_at
+        before update on exif
+        for each row execute procedure updated_at()
+    `);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`DROP INDEX "public"."IDX_asset_exif_update_id"`);
+    await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "updateId"`);
+    await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "updatedAt"`);
+    await queryRunner.query(`DROP TRIGGER asset_exif_updated_at on exif`);
+  }
+}
diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql
index c0b778bb50..1fd8f55f31 100644
--- a/server/src/queries/asset.repository.sql
+++ b/server/src/queries/asset.repository.sql
@@ -420,8 +420,8 @@ from
   ) as "stacked_assets" on "asset_stack"."id" is not null
 where
   "assets"."ownerId" = $1::uuid
-  and "isVisible" = $2
-  and "updatedAt" <= $3
+  and "assets"."isVisible" = $2
+  and "assets"."updatedAt" <= $3
   and "assets"."id" > $4
 order by
   "assets"."id"
@@ -450,7 +450,7 @@ from
   ) as "stacked_assets" on "asset_stack"."id" is not null
 where
   "assets"."ownerId" = any ($1::uuid[])
-  and "isVisible" = $2
-  and "updatedAt" > $3
+  and "assets"."isVisible" = $2
+  and "assets"."updatedAt" > $3
 limit
   $4
diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts
index 5f020ba836..1e6429b32d 100644
--- a/server/src/repositories/asset.repository.ts
+++ b/server/src/repositories/asset.repository.ts
@@ -551,7 +551,7 @@ export class AssetRepository {
     return this.getById(asset.id, { exifInfo: true, faces: { person: true } }) as Promise<AssetEntity>;
   }
 
-  async remove(asset: AssetEntity): Promise<void> {
+  async remove(asset: { id: string }): Promise<void> {
     await this.db.deleteFrom('assets').where('id', '=', asUuid(asset.id)).execute();
   }
 
@@ -968,8 +968,8 @@ export class AssetRepository {
       )
       .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
       .where('assets.ownerId', '=', asUuid(ownerId))
-      .where('isVisible', '=', true)
-      .where('updatedAt', '<=', updatedUntil)
+      .where('assets.isVisible', '=', true)
+      .where('assets.updatedAt', '<=', updatedUntil)
       .$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!))
       .orderBy('assets.id')
       .limit(limit)
@@ -996,8 +996,8 @@ export class AssetRepository {
       )
       .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
       .where('assets.ownerId', '=', anyUuid(options.userIds))
-      .where('isVisible', '=', true)
-      .where('updatedAt', '>', options.updatedAfter)
+      .where('assets.isVisible', '=', true)
+      .where('assets.updatedAt', '>', options.updatedAfter)
       .limit(options.limit)
       .execute() as any as Promise<AssetEntity[]>;
   }
diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts
index f2c5a1fc16..bc3205c0a3 100644
--- a/server/src/repositories/sync.repository.ts
+++ b/server/src/repositories/sync.repository.ts
@@ -1,10 +1,14 @@
 import { Injectable } from '@nestjs/common';
-import { Insertable, Kysely, sql } from 'kysely';
+import { Insertable, Kysely, SelectQueryBuilder, sql } from 'kysely';
 import { InjectKysely } from 'nestjs-kysely';
+import { columns } from 'src/database';
 import { DB, SessionSyncCheckpoints } from 'src/db';
 import { SyncEntityType } from 'src/enum';
 import { SyncAck } from 'src/types';
 
+type auditTables = 'users_audit' | 'partners_audit' | 'assets_audit';
+type upsertTables = 'users' | 'partners' | 'assets' | 'exif';
+
 @Injectable()
 export class SyncRepository {
   constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -41,9 +45,7 @@ export class SyncRepository {
     return this.db
       .selectFrom('users')
       .select(['id', 'name', 'email', 'deletedAt', 'updateId'])
-      .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId))
-      .where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
-      .orderBy(['updateId asc'])
+      .$call((qb) => this.upsertTableFilters(qb, ack))
       .stream();
   }
 
@@ -51,9 +53,7 @@ export class SyncRepository {
     return this.db
       .selectFrom('users_audit')
       .select(['id', 'userId'])
-      .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
-      .where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
-      .orderBy(['id asc'])
+      .$call((qb) => this.auditTableFilters(qb, ack))
       .stream();
   }
 
@@ -61,10 +61,8 @@ export class SyncRepository {
     return this.db
       .selectFrom('partners')
       .select(['sharedById', 'sharedWithId', 'inTimeline', 'updateId'])
-      .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId))
       .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)]))
-      .where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
-      .orderBy(['updateId asc'])
+      .$call((qb) => this.upsertTableFilters(qb, ack))
       .stream();
   }
 
@@ -72,10 +70,93 @@ export class SyncRepository {
     return this.db
       .selectFrom('partners_audit')
       .select(['id', 'sharedById', 'sharedWithId'])
-      .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
       .where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)]))
-      .where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
-      .orderBy(['id asc'])
+      .$call((qb) => this.auditTableFilters(qb, ack))
       .stream();
   }
+
+  getAssetUpserts(userId: string, ack?: SyncAck) {
+    return this.db
+      .selectFrom('assets')
+      .select(columns.syncAsset)
+      .where('ownerId', '=', userId)
+      .$call((qb) => this.upsertTableFilters(qb, ack))
+      .stream();
+  }
+
+  getPartnerAssetsUpserts(userId: string, ack?: SyncAck) {
+    return this.db
+      .selectFrom('assets')
+      .select(columns.syncAsset)
+      .where('ownerId', 'in', (eb) =>
+        eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
+      )
+      .$call((qb) => this.upsertTableFilters(qb, ack))
+      .stream();
+  }
+
+  getAssetDeletes(userId: string, ack?: SyncAck) {
+    return this.db
+      .selectFrom('assets_audit')
+      .select(['id', 'assetId'])
+      .where('ownerId', '=', userId)
+      .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
+      .$call((qb) => this.auditTableFilters(qb, ack))
+      .stream();
+  }
+
+  getPartnerAssetDeletes(userId: string, ack?: SyncAck) {
+    return this.db
+      .selectFrom('assets_audit')
+      .select(['id', 'assetId'])
+      .where('ownerId', 'in', (eb) =>
+        eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
+      )
+      .$call((qb) => this.auditTableFilters(qb, ack))
+      .stream();
+  }
+
+  getAssetExifsUpserts(userId: string, ack?: SyncAck) {
+    return this.db
+      .selectFrom('exif')
+      .select(columns.syncAssetExif)
+      .where('assetId', 'in', (eb) => eb.selectFrom('assets').select('id').where('ownerId', '=', userId))
+      .$call((qb) => this.upsertTableFilters(qb, ack))
+      .stream();
+  }
+
+  getPartnerAssetExifsUpserts(userId: string, ack?: SyncAck) {
+    return this.db
+      .selectFrom('exif')
+      .select(columns.syncAssetExif)
+      .where('assetId', 'in', (eb) =>
+        eb
+          .selectFrom('assets')
+          .select('id')
+          .where('ownerId', 'in', (eb) =>
+            eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
+          ),
+      )
+      .$call((qb) => this.upsertTableFilters(qb, ack))
+      .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>(
+    qb: SelectQueryBuilder<DB, T, D>,
+    ack?: SyncAck,
+  ) {
+    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))
+      .orderBy(['updateId asc']) as SelectQueryBuilder<DB, T, D>;
+  }
 }
diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts
index 45b1b7ff84..c88348b39e 100644
--- a/server/src/services/sync.service.ts
+++ b/server/src/services/sync.service.ts
@@ -4,7 +4,7 @@ import { DateTime } from 'luxon';
 import { Writable } from 'node:stream';
 import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
 import { SessionSyncCheckpoints } from 'src/db';
-import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
+import { AssetResponseDto, hexOrBufferToBase64, mapAsset } from 'src/dtos/asset-response.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
 import {
   AssetDeltaSyncDto,
@@ -22,10 +22,14 @@ import { setIsEqual } from 'src/utils/set';
 import { fromAck, serialize } from 'src/utils/sync';
 
 const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
-const SYNC_TYPES_ORDER = [
+export const SYNC_TYPES_ORDER = [
   //
   SyncRequestType.UsersV1,
   SyncRequestType.PartnersV1,
+  SyncRequestType.AssetsV1,
+  SyncRequestType.AssetExifsV1,
+  SyncRequestType.PartnerAssetsV1,
+  SyncRequestType.PartnerAssetExifsV1,
 ];
 
 const throwSessionRequired = () => {
@@ -49,17 +53,22 @@ export class SyncService extends BaseService {
       return throwSessionRequired();
     }
 
-    const checkpoints: Insertable<SessionSyncCheckpoints>[] = [];
+    const checkpoints: Record<string, Insertable<SessionSyncCheckpoints>> = {};
     for (const ack of dto.acks) {
       const { type } = fromAck(ack);
       // TODO proper ack validation via class validator
       if (!Object.values(SyncEntityType).includes(type)) {
         throw new BadRequestException(`Invalid ack type: ${type}`);
       }
-      checkpoints.push({ sessionId, type, ack });
+
+      if (checkpoints[type]) {
+        throw new BadRequestException('Only one ack per type is allowed');
+      }
+
+      checkpoints[type] = { sessionId, type, ack };
     }
 
-    await this.syncRepository.upsertCheckpoints(checkpoints);
+    await this.syncRepository.upsertCheckpoints(Object.values(checkpoints));
   }
 
   async deleteAcks(auth: AuthDto, dto: SyncAckDeleteDto) {
@@ -115,6 +124,87 @@ export class SyncService extends BaseService {
           break;
         }
 
+        case SyncRequestType.AssetsV1: {
+          const deletes = this.syncRepository.getAssetDeletes(
+            auth.user.id,
+            checkpointMap[SyncEntityType.AssetDeleteV1],
+          );
+          for await (const { id, ...data } of deletes) {
+            response.write(serialize({ type: SyncEntityType.AssetDeleteV1, updateId: id, data }));
+          }
+
+          const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetV1]);
+          for await (const { updateId, checksum, thumbhash, ...data } of upserts) {
+            response.write(
+              serialize({
+                type: SyncEntityType.AssetV1,
+                updateId,
+                data: {
+                  ...data,
+                  checksum: hexOrBufferToBase64(checksum),
+                  thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null,
+                },
+              }),
+            );
+          }
+
+          break;
+        }
+
+        case SyncRequestType.PartnerAssetsV1: {
+          const deletes = this.syncRepository.getPartnerAssetDeletes(
+            auth.user.id,
+            checkpointMap[SyncEntityType.PartnerAssetDeleteV1],
+          );
+          for await (const { id, ...data } of deletes) {
+            response.write(serialize({ type: SyncEntityType.PartnerAssetDeleteV1, updateId: id, data }));
+          }
+
+          const upserts = this.syncRepository.getPartnerAssetsUpserts(
+            auth.user.id,
+            checkpointMap[SyncEntityType.PartnerAssetV1],
+          );
+          for await (const { updateId, checksum, thumbhash, ...data } of upserts) {
+            response.write(
+              serialize({
+                type: SyncEntityType.PartnerAssetV1,
+                updateId,
+                data: {
+                  ...data,
+                  checksum: hexOrBufferToBase64(checksum),
+                  thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null,
+                },
+              }),
+            );
+          }
+
+          break;
+        }
+
+        case SyncRequestType.AssetExifsV1: {
+          const upserts = this.syncRepository.getAssetExifsUpserts(
+            auth.user.id,
+            checkpointMap[SyncEntityType.AssetExifV1],
+          );
+          for await (const { updateId, ...data } of upserts) {
+            response.write(serialize({ type: SyncEntityType.AssetExifV1, updateId, data }));
+          }
+
+          break;
+        }
+
+        case SyncRequestType.PartnerAssetExifsV1: {
+          const upserts = this.syncRepository.getPartnerAssetExifsUpserts(
+            auth.user.id,
+            checkpointMap[SyncEntityType.PartnerAssetExifV1],
+          );
+          for await (const { updateId, ...data } of upserts) {
+            response.write(serialize({ type: SyncEntityType.PartnerAssetExifV1, updateId, data }));
+          }
+
+          break;
+        }
+
         default: {
           this.logger.warn(`Unsupported sync type: ${type}`);
           break;
diff --git a/server/test/factory.ts b/server/test/factory.ts
index 9cb2c818ab..66c2fbb50c 100644
--- a/server/test/factory.ts
+++ b/server/test/factory.ts
@@ -55,7 +55,7 @@ class CustomWritable extends Writable {
   }
 }
 
-type Asset = Insertable<Assets>;
+type Asset = Partial<Insertable<Assets>>;
 type User = Partial<Insertable<Users>>;
 type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string };
 type Partner = Insertable<Partners>;
@@ -160,10 +160,6 @@ export class TestFactory {
   }
 
   async create() {
-    for (const asset of this.assets) {
-      await this.context.createAsset(asset);
-    }
-
     for (const user of this.users) {
       await this.context.createUser(user);
     }
@@ -176,6 +172,10 @@ export class TestFactory {
       await this.context.createSession(session);
     }
 
+    for (const asset of this.assets) {
+      await this.context.createAsset(asset);
+    }
+
     return this.context;
   }
 }
@@ -212,7 +212,7 @@ export class TestContext {
   versionHistory: VersionHistoryRepository;
   view: ViewRepository;
 
-  private constructor(private db: Kysely<DB>) {
+  private constructor(public db: Kysely<DB>) {
     const logger = newLoggingRepositoryMock() as unknown as LoggingRepository;
     const config = new ConfigRepository();
 
diff --git a/server/test/medium/specs/audit.database.spec.ts b/server/test/medium/specs/audit.database.spec.ts
new file mode 100644
index 0000000000..5332193e4c
--- /dev/null
+++ b/server/test/medium/specs/audit.database.spec.ts
@@ -0,0 +1,74 @@
+import { TestContext, TestFactory } from 'test/factory';
+import { getKyselyDB } from 'test/utils';
+
+describe('audit', () => {
+  let context: TestContext;
+
+  beforeAll(async () => {
+    const db = await getKyselyDB();
+    context = await TestContext.from(db).create();
+  });
+
+  describe('partners_audit', () => {
+    it('should not cascade user deletes to partners_audit', async () => {
+      const user1 = TestFactory.user();
+      const user2 = TestFactory.user();
+
+      await context
+        .getFactory()
+        .withUser(user1)
+        .withUser(user2)
+        .withPartner({ sharedById: user1.id, sharedWithId: user2.id })
+        .create();
+
+      await context.user.delete(user1, true);
+
+      await expect(
+        context.db.selectFrom('partners_audit').select(['id']).where('sharedById', '=', user1.id).execute(),
+      ).resolves.toHaveLength(0);
+    });
+  });
+
+  describe('assets_audit', () => {
+    it('should not cascade user deletes to assets_audit', async () => {
+      const user = TestFactory.user();
+      const asset = TestFactory.asset({ ownerId: user.id });
+
+      await context.getFactory().withUser(user).withAsset(asset).create();
+
+      await context.user.delete(user, true);
+
+      await expect(
+        context.db.selectFrom('assets_audit').select(['id']).where('assetId', '=', asset.id).execute(),
+      ).resolves.toHaveLength(0);
+    });
+  });
+
+  describe('exif', () => {
+    it('should automatically set updatedAt and updateId when the row is updated', async () => {
+      const user = TestFactory.user();
+      const asset = TestFactory.asset({ ownerId: user.id });
+      const exif = { assetId: asset.id, make: 'Canon' };
+
+      await context.getFactory().withUser(user).withAsset(asset).create();
+      await context.asset.upsertExif(exif);
+
+      const before = await context.db
+        .selectFrom('exif')
+        .select(['updatedAt', 'updateId'])
+        .where('assetId', '=', asset.id)
+        .executeTakeFirstOrThrow();
+
+      await context.asset.upsertExif({ assetId: asset.id, make: 'Canon 2' });
+
+      const after = await context.db
+        .selectFrom('exif')
+        .select(['updatedAt', 'updateId'])
+        .where('assetId', '=', asset.id)
+        .executeTakeFirstOrThrow();
+
+      expect(before.updateId).not.toEqual(after.updateId);
+      expect(before.updatedAt).not.toEqual(after.updatedAt);
+    });
+  });
+});
diff --git a/server/test/medium/specs/sync.service.spec.ts b/server/test/medium/specs/sync.service.spec.ts
index b33b010258..574ddde93c 100644
--- a/server/test/medium/specs/sync.service.spec.ts
+++ b/server/test/medium/specs/sync.service.spec.ts
@@ -1,6 +1,6 @@
 import { AuthDto } from 'src/dtos/auth.dto';
-import { SyncRequestType } from 'src/enum';
-import { SyncService } from 'src/services/sync.service';
+import { SyncEntityType, SyncRequestType } from 'src/enum';
+import { SYNC_TYPES_ORDER, SyncService } from 'src/services/sync.service';
 import { TestContext, TestFactory } from 'test/factory';
 import { getKyselyDB, newTestService } from 'test/utils';
 
@@ -33,7 +33,15 @@ const setup = async () => {
 };
 
 describe(SyncService.name, () => {
-  describe.concurrent('users', () => {
+  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 { context, auth, sut, testSync } = await setup();
 
@@ -189,7 +197,7 @@ describe(SyncService.name, () => {
     });
   });
 
-  describe.concurrent('partners', () => {
+  describe.concurrent(SyncEntityType.PartnerV1, () => {
     it('should detect and sync the first partner', async () => {
       const { auth, context, sut, testSync } = await setup();
 
@@ -349,7 +357,7 @@ describe(SyncService.name, () => {
       );
     });
 
-    it('should not sync a partner for an unrelated user', async () => {
+    it('should not sync a partner or partner delete for an unrelated user', async () => {
       const { auth, context, testSync } = await setup();
 
       const user2 = await context.createUser();
@@ -357,9 +365,436 @@ describe(SyncService.name, () => {
 
       await context.createPartner({ sharedById: user2.id, sharedWithId: user3.id });
 
-      const response = await testSync(auth, [SyncRequestType.PartnersV1]);
+      expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
+
+      await context.partner.remove({ sharedById: user2.id, sharedWithId: user3.id });
+
+      expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
+    });
+
+    it('should not sync a partner delete after a user is deleted', async () => {
+      const { auth, context, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      await context.createPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
+      await context.user.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, context, sut, testSync } = await setup();
+
+      const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
+      const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
+      const date = new Date().toISOString();
+
+      const asset = TestFactory.asset({
+        ownerId: auth.user.id,
+        checksum: Buffer.from(checksum, 'base64'),
+        thumbhash: Buffer.from(thumbhash, 'base64'),
+        fileCreatedAt: date,
+        fileModifiedAt: date,
+        deletedAt: null,
+      });
+      await context.createAsset(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: null,
+              fileCreatedAt: date,
+              fileModifiedAt: date,
+              isFavorite: false,
+              isVisible: true,
+              localDateTime: null,
+              type: asset.type,
+            },
+            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, context, sut, testSync } = await setup();
+
+      const asset = TestFactory.asset({ ownerId: auth.user.id });
+      await context.createAsset(asset);
+      await context.asset.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, context, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      const session = TestFactory.session({ userId: user2.id });
+      const auth2 = TestFactory.auth({ session, user: user2 });
+
+      const asset = TestFactory.asset({ ownerId: user2.id });
+      await context.createAsset(asset);
+
+      expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
+      expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
+
+      await context.asset.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, context, sut, testSync } = await setup();
+
+      const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
+      const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
+      const date = new Date().toISOString();
+
+      const user2 = await context.createUser();
+
+      const asset = TestFactory.asset({
+        ownerId: user2.id,
+        checksum: Buffer.from(checksum, 'base64'),
+        thumbhash: Buffer.from(thumbhash, 'base64'),
+        fileCreatedAt: date,
+        fileModifiedAt: date,
+        deletedAt: null,
+      });
+      await context.createAsset(asset);
+      await context.partner.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,
+              isVisible: true,
+              localDateTime: null,
+              type: asset.type,
+            },
+            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, context, sut, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      const asset = TestFactory.asset({ ownerId: user2.id });
+      await context.createAsset(asset);
+      await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+      await context.asset.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, context, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+      await context.createAsset({ ownerId: user2.id });
+      await context.user.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, context, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      await context.createAsset({ ownerId: user2.id });
+      const partner = { sharedById: user2.id, sharedWithId: auth.user.id };
+      await context.partner.create(partner);
+
+      await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1);
+
+      await context.partner.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, context, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      const asset = await context.createAsset({ ownerId: auth.user.id });
+      const partner = { sharedById: user2.id, sharedWithId: auth.user.id };
+      await context.partner.create(partner);
+
+      await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
+      await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
+
+      await context.asset.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, context, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      const session = TestFactory.session({ userId: user2.id });
+      const auth2 = TestFactory.auth({ session, user: user2 });
+      const asset = await context.createAsset({ ownerId: user2.id });
+
+      await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
+      await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
+
+      await context.asset.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, context, sut, testSync } = await setup();
+
+      const asset = TestFactory.asset({ ownerId: auth.user.id });
+      const exif = { assetId: asset.id, make: 'Canon' };
+
+      await context.createAsset(asset);
+      await context.asset.upsertExif(exif);
+
+      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, context, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      const session = TestFactory.session({ userId: user2.id });
+      const auth2 = TestFactory.auth({ session, user: user2 });
+
+      await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+      const asset = TestFactory.asset({ ownerId: user2.id });
+      const exif = { assetId: asset.id, make: 'Canon' };
+
+      await context.createAsset(asset);
+      await context.asset.upsertExif(exif);
+
+      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, context, sut, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+      const asset = TestFactory.asset({ ownerId: user2.id });
+      await context.createAsset(asset);
+      const exif = { assetId: asset.id, make: 'Canon' };
+      await context.asset.upsertExif(exif);
+
+      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, context, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+      const asset = TestFactory.asset({ ownerId: auth.user.id });
+      const exif = { assetId: asset.id, make: 'Canon' };
+      await context.createAsset(asset);
+      await context.asset.upsertExif(exif);
+
+      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, context, testSync } = await setup();
+
+      const user2 = await context.createUser();
+      const user3 = await context.createUser();
+      const session = TestFactory.session({ userId: user3.id });
+      const authUser3 = TestFactory.auth({ session, user: user3 });
+      await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
+      const asset = TestFactory.asset({ ownerId: user3.id });
+      const exif = { assetId: asset.id, make: 'Canon' };
+      await context.createAsset(asset);
+      await context.asset.upsertExif(exif);
+
+      await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
+      await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
+    });
   });
 });
diff --git a/server/test/repositories/sync.repository.mock.ts b/server/test/repositories/sync.repository.mock.ts
index 6d94f6e039..c7fb154db7 100644
--- a/server/test/repositories/sync.repository.mock.ts
+++ b/server/test/repositories/sync.repository.mock.ts
@@ -11,5 +11,11 @@ export const newSyncRepositoryMock = (): Mocked<RepositoryInterface<SyncReposito
     getUserDeletes: vitest.fn(),
     getPartnerUpserts: vitest.fn(),
     getPartnerDeletes: vitest.fn(),
+    getPartnerAssetsUpserts: vitest.fn(),
+    getPartnerAssetDeletes: vitest.fn(),
+    getAssetDeletes: vitest.fn(),
+    getAssetUpserts: vitest.fn(),
+    getAssetExifsUpserts: vitest.fn(),
+    getPartnerAssetExifsUpserts: vitest.fn(),
   };
 };