From 6feca56da8c4b39d6cf13b5d6d856fd59dbbdea1 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 27 Jun 2025 12:20:13 -0400 Subject: [PATCH] feat: sync memories (#19579) --- mobile/openapi/README.md | 4 + mobile/openapi/lib/api.dart | 4 + mobile/openapi/lib/api_client.dart | 8 + .../openapi/lib/model/sync_entity_type.dart | 24 +- .../model/sync_memory_asset_delete_v1.dart | 107 ++++++++ .../lib/model/sync_memory_asset_v1.dart | 107 ++++++++ .../lib/model/sync_memory_delete_v1.dart | 99 ++++++++ mobile/openapi/lib/model/sync_memory_v1.dart | 203 +++++++++++++++ .../openapi/lib/model/sync_request_type.dart | 6 + open-api/immich-openapi-specs.json | 123 ++++++++- open-api/typescript-sdk/src/fetch-client.ts | 12 +- server/src/database.ts | 4 +- server/src/db.d.ts | 16 +- server/src/dtos/sync.dto.ts | 90 +++++-- server/src/enum.ts | 17 +- server/src/queries/sync.repository.sql | 75 ++++++ server/src/repositories/sync.repository.ts | 69 ++++- server/src/schema/functions.ts | 28 +++ server/src/schema/index.ts | 12 +- .../1751035357937-MemorySyncChanges.ts | 80 ++++++ server/src/schema/tables/index.ts | 2 +- .../schema/tables/memory-asset-audit.table.ts | 23 ++ .../src/schema/tables/memory-asset.table.ts | 36 +++ .../src/schema/tables/memory-audit.table.ts | 22 ++ server/src/schema/tables/memory.table.ts | 16 +- .../src/schema/tables/memory_asset.table.ts | 12 - server/src/services/memory.service.spec.ts | 11 +- server/src/services/sync.service.ts | 238 ++++++++---------- server/test/medium.factory.ts | 38 ++- .../specs/sync/sync-memory-asset.spec.ts | 84 +++++++ .../medium/specs/sync/sync-memory.spec.ts | 115 +++++++++ 31 files changed, 1482 insertions(+), 203 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_memory_asset_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_memory_asset_v1.dart create mode 100644 mobile/openapi/lib/model/sync_memory_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_memory_v1.dart create mode 100644 server/src/schema/migrations/1751035357937-MemorySyncChanges.ts create mode 100644 server/src/schema/tables/memory-asset-audit.table.ts create mode 100644 server/src/schema/tables/memory-asset.table.ts create mode 100644 server/src/schema/tables/memory-audit.table.ts delete mode 100644 server/src/schema/tables/memory_asset.table.ts create mode 100644 server/test/medium/specs/sync/sync-memory-asset.spec.ts create mode 100644 server/test/medium/specs/sync/sync-memory.spec.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b09f36548a..d9607ef2ec 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -475,6 +475,10 @@ Class | Method | HTTP request | Description - [SyncAssetExifV1](doc//SyncAssetExifV1.md) - [SyncAssetV1](doc//SyncAssetV1.md) - [SyncEntityType](doc//SyncEntityType.md) + - [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md) + - [SyncMemoryAssetV1](doc//SyncMemoryAssetV1.md) + - [SyncMemoryDeleteV1](doc//SyncMemoryDeleteV1.md) + - [SyncMemoryV1](doc//SyncMemoryV1.md) - [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md) - [SyncPartnerV1](doc//SyncPartnerV1.md) - [SyncRequestType](doc//SyncRequestType.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index df61b8ab00..67f75fba38 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -258,6 +258,10 @@ 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_memory_asset_delete_v1.dart'; +part 'model/sync_memory_asset_v1.dart'; +part 'model/sync_memory_delete_v1.dart'; +part 'model/sync_memory_v1.dart'; part 'model/sync_partner_delete_v1.dart'; part 'model/sync_partner_v1.dart'; part 'model/sync_request_type.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 18b2c9202b..d0f7837bc9 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -572,6 +572,14 @@ class ApiClient { return SyncAssetV1.fromJson(value); case 'SyncEntityType': return SyncEntityTypeTypeTransformer().decode(value); + case 'SyncMemoryAssetDeleteV1': + return SyncMemoryAssetDeleteV1.fromJson(value); + case 'SyncMemoryAssetV1': + return SyncMemoryAssetV1.fromJson(value); + case 'SyncMemoryDeleteV1': + return SyncMemoryDeleteV1.fromJson(value); + case 'SyncMemoryV1': + return SyncMemoryV1.fromJson(value); case 'SyncPartnerDeleteV1': return SyncPartnerDeleteV1.fromJson(value); case 'SyncPartnerV1': diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 7129ebc0a4..fb530240ed 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -25,11 +25,11 @@ class SyncEntityType { static const userV1 = SyncEntityType._(r'UserV1'); 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 partnerV1 = SyncEntityType._(r'PartnerV1'); + static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1'); static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1'); static const partnerAssetBackfillV1 = SyncEntityType._(r'PartnerAssetBackfillV1'); static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1'); @@ -47,17 +47,21 @@ class SyncEntityType { static const albumToAssetV1 = SyncEntityType._(r'AlbumToAssetV1'); static const albumToAssetDeleteV1 = SyncEntityType._(r'AlbumToAssetDeleteV1'); static const albumToAssetBackfillV1 = SyncEntityType._(r'AlbumToAssetBackfillV1'); + static const memoryV1 = SyncEntityType._(r'MemoryV1'); + static const memoryDeleteV1 = SyncEntityType._(r'MemoryDeleteV1'); + static const memoryToAssetV1 = SyncEntityType._(r'MemoryToAssetV1'); + static const memoryToAssetDeleteV1 = SyncEntityType._(r'MemoryToAssetDeleteV1'); static const syncAckV1 = SyncEntityType._(r'SyncAckV1'); /// List of all possible values in this [enum][SyncEntityType]. static const values = [ userV1, userDeleteV1, - partnerV1, - partnerDeleteV1, assetV1, assetDeleteV1, assetExifV1, + partnerV1, + partnerDeleteV1, partnerAssetV1, partnerAssetBackfillV1, partnerAssetDeleteV1, @@ -75,6 +79,10 @@ class SyncEntityType { albumToAssetV1, albumToAssetDeleteV1, albumToAssetBackfillV1, + memoryV1, + memoryDeleteV1, + memoryToAssetV1, + memoryToAssetDeleteV1, syncAckV1, ]; @@ -116,11 +124,11 @@ class SyncEntityTypeTypeTransformer { switch (data) { case r'UserV1': return SyncEntityType.userV1; 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'PartnerV1': return SyncEntityType.partnerV1; + case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1; case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1; case r'PartnerAssetBackfillV1': return SyncEntityType.partnerAssetBackfillV1; case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1; @@ -138,6 +146,10 @@ class SyncEntityTypeTypeTransformer { case r'AlbumToAssetV1': return SyncEntityType.albumToAssetV1; case r'AlbumToAssetDeleteV1': return SyncEntityType.albumToAssetDeleteV1; case r'AlbumToAssetBackfillV1': return SyncEntityType.albumToAssetBackfillV1; + case r'MemoryV1': return SyncEntityType.memoryV1; + case r'MemoryDeleteV1': return SyncEntityType.memoryDeleteV1; + case r'MemoryToAssetV1': return SyncEntityType.memoryToAssetV1; + case r'MemoryToAssetDeleteV1': return SyncEntityType.memoryToAssetDeleteV1; case r'SyncAckV1': return SyncEntityType.syncAckV1; default: if (!allowNull) { diff --git a/mobile/openapi/lib/model/sync_memory_asset_delete_v1.dart b/mobile/openapi/lib/model/sync_memory_asset_delete_v1.dart new file mode 100644 index 0000000000..a9af77e929 --- /dev/null +++ b/mobile/openapi/lib/model/sync_memory_asset_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 SyncMemoryAssetDeleteV1 { + /// Returns a new [SyncMemoryAssetDeleteV1] instance. + SyncMemoryAssetDeleteV1({ + required this.assetId, + required this.memoryId, + }); + + String assetId; + + String memoryId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncMemoryAssetDeleteV1 && + other.assetId == assetId && + other.memoryId == memoryId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (memoryId.hashCode); + + @override + String toString() => 'SyncMemoryAssetDeleteV1[assetId=$assetId, memoryId=$memoryId]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'memoryId'] = this.memoryId; + return json; + } + + /// Returns a new [SyncMemoryAssetDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncMemoryAssetDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncMemoryAssetDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncMemoryAssetDeleteV1( + assetId: mapValueOfType(json, r'assetId')!, + memoryId: mapValueOfType(json, r'memoryId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncMemoryAssetDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncMemoryAssetDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncMemoryAssetDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncMemoryAssetDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'memoryId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_memory_asset_v1.dart b/mobile/openapi/lib/model/sync_memory_asset_v1.dart new file mode 100644 index 0000000000..d26e3c9a29 --- /dev/null +++ b/mobile/openapi/lib/model/sync_memory_asset_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 SyncMemoryAssetV1 { + /// Returns a new [SyncMemoryAssetV1] instance. + SyncMemoryAssetV1({ + required this.assetId, + required this.memoryId, + }); + + String assetId; + + String memoryId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncMemoryAssetV1 && + other.assetId == assetId && + other.memoryId == memoryId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (memoryId.hashCode); + + @override + String toString() => 'SyncMemoryAssetV1[assetId=$assetId, memoryId=$memoryId]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'memoryId'] = this.memoryId; + return json; + } + + /// Returns a new [SyncMemoryAssetV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncMemoryAssetV1? fromJson(dynamic value) { + upgradeDto(value, "SyncMemoryAssetV1"); + if (value is Map) { + final json = value.cast(); + + return SyncMemoryAssetV1( + assetId: mapValueOfType(json, r'assetId')!, + memoryId: mapValueOfType(json, r'memoryId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncMemoryAssetV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncMemoryAssetV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncMemoryAssetV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncMemoryAssetV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'memoryId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_memory_delete_v1.dart b/mobile/openapi/lib/model/sync_memory_delete_v1.dart new file mode 100644 index 0000000000..9702da5aaf --- /dev/null +++ b/mobile/openapi/lib/model/sync_memory_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 SyncMemoryDeleteV1 { + /// Returns a new [SyncMemoryDeleteV1] instance. + SyncMemoryDeleteV1({ + required this.memoryId, + }); + + String memoryId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncMemoryDeleteV1 && + other.memoryId == memoryId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (memoryId.hashCode); + + @override + String toString() => 'SyncMemoryDeleteV1[memoryId=$memoryId]'; + + Map toJson() { + final json = {}; + json[r'memoryId'] = this.memoryId; + return json; + } + + /// Returns a new [SyncMemoryDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncMemoryDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncMemoryDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncMemoryDeleteV1( + memoryId: mapValueOfType(json, r'memoryId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncMemoryDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncMemoryDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncMemoryDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncMemoryDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'memoryId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_memory_v1.dart b/mobile/openapi/lib/model/sync_memory_v1.dart new file mode 100644 index 0000000000..2ae2b01fd7 --- /dev/null +++ b/mobile/openapi/lib/model/sync_memory_v1.dart @@ -0,0 +1,203 @@ +// +// 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 SyncMemoryV1 { + /// Returns a new [SyncMemoryV1] instance. + SyncMemoryV1({ + required this.createdAt, + required this.data, + required this.deletedAt, + required this.hideAt, + required this.id, + required this.isSaved, + required this.memoryAt, + required this.ownerId, + required this.seenAt, + required this.showAt, + required this.type, + required this.updatedAt, + }); + + DateTime createdAt; + + Object data; + + DateTime? deletedAt; + + DateTime? hideAt; + + String id; + + bool isSaved; + + DateTime memoryAt; + + String ownerId; + + DateTime? seenAt; + + DateTime? showAt; + + MemoryType type; + + DateTime updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncMemoryV1 && + other.createdAt == createdAt && + other.data == data && + other.deletedAt == deletedAt && + other.hideAt == hideAt && + other.id == id && + other.isSaved == isSaved && + other.memoryAt == memoryAt && + other.ownerId == ownerId && + other.seenAt == seenAt && + other.showAt == showAt && + other.type == type && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (createdAt.hashCode) + + (data.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (hideAt == null ? 0 : hideAt!.hashCode) + + (id.hashCode) + + (isSaved.hashCode) + + (memoryAt.hashCode) + + (ownerId.hashCode) + + (seenAt == null ? 0 : seenAt!.hashCode) + + (showAt == null ? 0 : showAt!.hashCode) + + (type.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'SyncMemoryV1[createdAt=$createdAt, data=$data, deletedAt=$deletedAt, hideAt=$hideAt, id=$id, isSaved=$isSaved, memoryAt=$memoryAt, ownerId=$ownerId, seenAt=$seenAt, showAt=$showAt, type=$type, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'data'] = this.data; + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + if (this.hideAt != null) { + json[r'hideAt'] = this.hideAt!.toUtc().toIso8601String(); + } else { + // json[r'hideAt'] = null; + } + json[r'id'] = this.id; + json[r'isSaved'] = this.isSaved; + json[r'memoryAt'] = this.memoryAt.toUtc().toIso8601String(); + json[r'ownerId'] = this.ownerId; + if (this.seenAt != null) { + json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String(); + } else { + // json[r'seenAt'] = null; + } + if (this.showAt != null) { + json[r'showAt'] = this.showAt!.toUtc().toIso8601String(); + } else { + // json[r'showAt'] = null; + } + json[r'type'] = this.type; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + return json; + } + + /// Returns a new [SyncMemoryV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncMemoryV1? fromJson(dynamic value) { + upgradeDto(value, "SyncMemoryV1"); + if (value is Map) { + final json = value.cast(); + + return SyncMemoryV1( + createdAt: mapDateTime(json, r'createdAt', r'')!, + data: mapValueOfType(json, r'data')!, + deletedAt: mapDateTime(json, r'deletedAt', r''), + hideAt: mapDateTime(json, r'hideAt', r''), + id: mapValueOfType(json, r'id')!, + isSaved: mapValueOfType(json, r'isSaved')!, + memoryAt: mapDateTime(json, r'memoryAt', r'')!, + ownerId: mapValueOfType(json, r'ownerId')!, + seenAt: mapDateTime(json, r'seenAt', r''), + showAt: mapDateTime(json, r'showAt', r''), + type: MemoryType.fromJson(json[r'type'])!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncMemoryV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncMemoryV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncMemoryV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncMemoryV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'createdAt', + 'data', + 'deletedAt', + 'hideAt', + 'id', + 'isSaved', + 'memoryAt', + 'ownerId', + 'seenAt', + 'showAt', + 'type', + 'updatedAt', + }; +} + diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index c3cce99b1a..21b71dbc5b 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -34,6 +34,8 @@ class SyncRequestType { static const albumToAssetsV1 = SyncRequestType._(r'AlbumToAssetsV1'); static const albumAssetsV1 = SyncRequestType._(r'AlbumAssetsV1'); static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1'); + static const memoriesV1 = SyncRequestType._(r'MemoriesV1'); + static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1'); /// List of all possible values in this [enum][SyncRequestType]. static const values = [ @@ -48,6 +50,8 @@ class SyncRequestType { albumToAssetsV1, albumAssetsV1, albumAssetExifsV1, + memoriesV1, + memoryToAssetsV1, ]; static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); @@ -97,6 +101,8 @@ class SyncRequestTypeTypeTransformer { case r'AlbumToAssetsV1': return SyncRequestType.albumToAssetsV1; case r'AlbumAssetsV1': return SyncRequestType.albumAssetsV1; case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1; + case r'MemoriesV1': return SyncRequestType.memoriesV1; + case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1; 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 79b019bb2d..5859331614 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13794,11 +13794,11 @@ "enum": [ "UserV1", "UserDeleteV1", - "PartnerV1", - "PartnerDeleteV1", "AssetV1", "AssetDeleteV1", "AssetExifV1", + "PartnerV1", + "PartnerDeleteV1", "PartnerAssetV1", "PartnerAssetBackfillV1", "PartnerAssetDeleteV1", @@ -13816,10 +13816,125 @@ "AlbumToAssetV1", "AlbumToAssetDeleteV1", "AlbumToAssetBackfillV1", + "MemoryV1", + "MemoryDeleteV1", + "MemoryToAssetV1", + "MemoryToAssetDeleteV1", "SyncAckV1" ], "type": "string" }, + "SyncMemoryAssetDeleteV1": { + "properties": { + "assetId": { + "type": "string" + }, + "memoryId": { + "type": "string" + } + }, + "required": [ + "assetId", + "memoryId" + ], + "type": "object" + }, + "SyncMemoryAssetV1": { + "properties": { + "assetId": { + "type": "string" + }, + "memoryId": { + "type": "string" + } + }, + "required": [ + "assetId", + "memoryId" + ], + "type": "object" + }, + "SyncMemoryDeleteV1": { + "properties": { + "memoryId": { + "type": "string" + } + }, + "required": [ + "memoryId" + ], + "type": "object" + }, + "SyncMemoryV1": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "data": { + "type": "object" + }, + "deletedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "hideAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "type": "string" + }, + "isSaved": { + "type": "boolean" + }, + "memoryAt": { + "format": "date-time", + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "seenAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "showAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/MemoryType" + } + ] + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "createdAt", + "data", + "deletedAt", + "hideAt", + "id", + "isSaved", + "memoryAt", + "ownerId", + "seenAt", + "showAt", + "type", + "updatedAt" + ], + "type": "object" + }, "SyncPartnerDeleteV1": { "properties": { "sharedById": { @@ -13866,7 +13981,9 @@ "AlbumUsersV1", "AlbumToAssetsV1", "AlbumAssetsV1", - "AlbumAssetExifsV1" + "AlbumAssetExifsV1", + "MemoriesV1", + "MemoryToAssetsV1" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5b94834105..a7016df710 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4063,11 +4063,11 @@ export enum Error2 { export enum SyncEntityType { UserV1 = "UserV1", UserDeleteV1 = "UserDeleteV1", - PartnerV1 = "PartnerV1", - PartnerDeleteV1 = "PartnerDeleteV1", AssetV1 = "AssetV1", AssetDeleteV1 = "AssetDeleteV1", AssetExifV1 = "AssetExifV1", + PartnerV1 = "PartnerV1", + PartnerDeleteV1 = "PartnerDeleteV1", PartnerAssetV1 = "PartnerAssetV1", PartnerAssetBackfillV1 = "PartnerAssetBackfillV1", PartnerAssetDeleteV1 = "PartnerAssetDeleteV1", @@ -4085,6 +4085,10 @@ export enum SyncEntityType { AlbumToAssetV1 = "AlbumToAssetV1", AlbumToAssetDeleteV1 = "AlbumToAssetDeleteV1", AlbumToAssetBackfillV1 = "AlbumToAssetBackfillV1", + MemoryV1 = "MemoryV1", + MemoryDeleteV1 = "MemoryDeleteV1", + MemoryToAssetV1 = "MemoryToAssetV1", + MemoryToAssetDeleteV1 = "MemoryToAssetDeleteV1", SyncAckV1 = "SyncAckV1" } export enum SyncRequestType { @@ -4098,7 +4102,9 @@ export enum SyncRequestType { AlbumUsersV1 = "AlbumUsersV1", AlbumToAssetsV1 = "AlbumToAssetsV1", AlbumAssetsV1 = "AlbumAssetsV1", - AlbumAssetExifsV1 = "AlbumAssetExifsV1" + AlbumAssetExifsV1 = "AlbumAssetExifsV1", + MemoriesV1 = "MemoriesV1", + MemoryToAssetsV1 = "MemoryToAssetsV1" } export enum TranscodeHWAccel { Nvenc = "nvenc", diff --git a/server/src/database.ts b/server/src/database.ts index 8a3c3b702b..4042617e98 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -13,7 +13,7 @@ import { UserAvatarColor, UserStatus, } from 'src/enum'; -import { OnThisDayData, UserMetadataItem } from 'src/types'; +import { UserMetadataItem } from 'src/types'; export type AuthUser = { id: string; @@ -95,7 +95,7 @@ export type Memory = { showAt: Date | null; hideAt: Date | null; type: MemoryType; - data: OnThisDayData; + data: object; ownerId: string; isSaved: boolean; assets: MapAsset[]; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 3fcf4ddea7..68ddd245f3 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -19,8 +19,11 @@ import { SourceType, SyncEntityType, } from 'src/enum'; +import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table'; +import { MemoryAssetTable } from 'src/schema/tables/memory-asset.table'; +import { MemoryAuditTable } from 'src/schema/tables/memory-audit.table'; import { UserTable } from 'src/schema/tables/user.table'; -import { OnThisDayData, UserMetadataItem } from 'src/types'; +import { UserMetadataItem } from 'src/types'; export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; @@ -278,7 +281,7 @@ export interface Libraries { export interface Memories { createdAt: Generated; - data: OnThisDayData; + data: object; deletedAt: Timestamp | null; hideAt: Timestamp | null; id: Generated; @@ -307,11 +310,6 @@ export interface Notifications { readAt: Timestamp | null; } -export interface MemoriesAssetsAssets { - assetsId: string; - memoriesId: string; -} - export interface Migrations { id: Generated; name: string; @@ -512,7 +510,9 @@ export interface DB { geodata_places: GeodataPlaces; libraries: Libraries; memories: Memories; - memories_assets_assets: MemoriesAssetsAssets; + memories_audit: MemoryAuditTable; + memories_assets_assets: MemoryAssetTable; + memory_assets_audit: MemoryAssetAuditTable; migrations: Migrations; notifications: Notifications; move_history: MoveHistory; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index d467791825..77f0578c36 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -1,7 +1,16 @@ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, IsEnum, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AlbumUserRole, AssetOrder, AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum'; +import { + AlbumUserRole, + AssetOrder, + AssetType, + AssetVisibility, + MemoryType, + SyncEntityType, + SyncRequestType, +} from 'src/enum'; import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; export class AssetFullSyncDto { @@ -34,6 +43,15 @@ export class AssetDeltaSyncResponseDto { deleted!: string[]; } +export const extraSyncModels: Function[] = []; + +export const ExtraModel = (): ClassDecorator => { + return (object: Function) => { + extraSyncModels.push(object); + }; +}; + +@ExtraModel() export class SyncUserV1 { id!: string; name!: string; @@ -41,21 +59,25 @@ export class SyncUserV1 { deletedAt!: Date | null; } +@ExtraModel() export class SyncUserDeleteV1 { userId!: string; } +@ExtraModel() export class SyncPartnerV1 { sharedById!: string; sharedWithId!: string; inTimeline!: boolean; } +@ExtraModel() export class SyncPartnerDeleteV1 { sharedById!: string; sharedWithId!: string; } +@ExtraModel() export class SyncAssetV1 { id!: string; ownerId!: string; @@ -74,10 +96,12 @@ export class SyncAssetV1 { visibility!: AssetVisibility; } +@ExtraModel() export class SyncAssetDeleteV1 { assetId!: string; } +@ExtraModel() export class SyncAssetExifV1 { assetId!: string; description!: string | null; @@ -116,15 +140,18 @@ export class SyncAssetExifV1 { fps!: number | null; } +@ExtraModel() export class SyncAlbumDeleteV1 { albumId!: string; } +@ExtraModel() export class SyncAlbumUserDeleteV1 { albumId!: string; userId!: string; } +@ExtraModel() export class SyncAlbumUserV1 { albumId!: string; userId!: string; @@ -132,6 +159,7 @@ export class SyncAlbumUserV1 { role!: AlbumUserRole; } +@ExtraModel() export class SyncAlbumV1 { id!: string; ownerId!: string; @@ -145,16 +173,53 @@ export class SyncAlbumV1 { order!: AssetOrder; } +@ExtraModel() export class SyncAlbumToAssetV1 { albumId!: string; assetId!: string; } +@ExtraModel() export class SyncAlbumToAssetDeleteV1 { albumId!: string; assetId!: string; } +@ExtraModel() +export class SyncMemoryV1 { + id!: string; + createdAt!: Date; + updatedAt!: Date; + deletedAt!: Date | null; + ownerId!: string; + @ApiProperty({ enumName: 'MemoryType', enum: MemoryType }) + type!: MemoryType; + data!: object; + isSaved!: boolean; + memoryAt!: Date; + seenAt!: Date | null; + showAt!: Date | null; + hideAt!: Date | null; +} + +@ExtraModel() +export class SyncMemoryDeleteV1 { + memoryId!: string; +} + +@ExtraModel() +export class SyncMemoryAssetV1 { + memoryId!: string; + assetId!: string; +} + +@ExtraModel() +export class SyncMemoryAssetDeleteV1 { + memoryId!: string; + assetId!: string; +} + +@ExtraModel() export class SyncAckV1 {} export type SyncItem = { @@ -182,28 +247,13 @@ export type SyncItem = { [SyncEntityType.AlbumToAssetV1]: SyncAlbumToAssetV1; [SyncEntityType.AlbumToAssetBackfillV1]: SyncAlbumToAssetV1; [SyncEntityType.AlbumToAssetDeleteV1]: SyncAlbumToAssetDeleteV1; + [SyncEntityType.MemoryV1]: SyncMemoryV1; + [SyncEntityType.MemoryDeleteV1]: SyncMemoryDeleteV1; + [SyncEntityType.MemoryToAssetV1]: SyncMemoryAssetV1; + [SyncEntityType.MemoryToAssetDeleteV1]: SyncMemoryAssetDeleteV1; [SyncEntityType.SyncAckV1]: SyncAckV1; }; -const responseDtos = [ - SyncUserV1, - SyncUserDeleteV1, - SyncPartnerV1, - SyncPartnerDeleteV1, - SyncAssetV1, - SyncAssetDeleteV1, - SyncAssetExifV1, - SyncAlbumV1, - SyncAlbumDeleteV1, - SyncAlbumUserV1, - SyncAlbumUserDeleteV1, - SyncAlbumToAssetV1, - SyncAlbumToAssetDeleteV1, - SyncAckV1, -]; - -export const extraSyncModels = responseDtos; - export class SyncStreamDto { @IsEnum(SyncRequestType, { each: true }) @ApiProperty({ enumName: 'SyncRequestType', enum: SyncRequestType, isArray: true }) diff --git a/server/src/enum.ts b/server/src/enum.ts index bbe8f001de..4a89baa6bd 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -584,19 +584,21 @@ export enum SyncRequestType { AlbumToAssetsV1 = 'AlbumToAssetsV1', AlbumAssetsV1 = 'AlbumAssetsV1', AlbumAssetExifsV1 = 'AlbumAssetExifsV1', + MemoriesV1 = 'MemoriesV1', + MemoryToAssetsV1 = 'MemoryToAssetsV1', } export enum SyncEntityType { UserV1 = 'UserV1', UserDeleteV1 = 'UserDeleteV1', - PartnerV1 = 'PartnerV1', - PartnerDeleteV1 = 'PartnerDeleteV1', - AssetV1 = 'AssetV1', AssetDeleteV1 = 'AssetDeleteV1', AssetExifV1 = 'AssetExifV1', + PartnerV1 = 'PartnerV1', + PartnerDeleteV1 = 'PartnerDeleteV1', + PartnerAssetV1 = 'PartnerAssetV1', PartnerAssetBackfillV1 = 'PartnerAssetBackfillV1', PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', @@ -605,17 +607,26 @@ export enum SyncEntityType { AlbumV1 = 'AlbumV1', AlbumDeleteV1 = 'AlbumDeleteV1', + AlbumUserV1 = 'AlbumUserV1', AlbumUserBackfillV1 = 'AlbumUserBackfillV1', AlbumUserDeleteV1 = 'AlbumUserDeleteV1', + AlbumAssetV1 = 'AlbumAssetV1', AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1', AlbumAssetExifV1 = 'AlbumAssetExifV1', AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1', + AlbumToAssetV1 = 'AlbumToAssetV1', AlbumToAssetDeleteV1 = 'AlbumToAssetDeleteV1', AlbumToAssetBackfillV1 = 'AlbumToAssetBackfillV1', + MemoryV1 = 'MemoryV1', + MemoryDeleteV1 = 'MemoryDeleteV1', + + MemoryToAssetV1 = 'MemoryToAssetV1', + MemoryToAssetDeleteV1 = 'MemoryToAssetDeleteV1', + SyncAckV1 = 'SyncAckV1', } diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 46b72ffca0..0abe985317 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -652,3 +652,78 @@ where ) order by "exif"."updateId" asc + +-- SyncRepository.getMemoryUpserts +select + "id", + "createdAt", + "updatedAt", + "deletedAt", + "ownerId", + "type", + "data", + "isSaved", + "memoryAt", + "seenAt", + "showAt", + "hideAt", + "updateId" +from + "memories" +where + "ownerId" = $1 + and "updatedAt" < now() - interval '1 millisecond' +order by + "updateId" asc + +-- SyncRepository.getMemoryDeletes +select + "id", + "memoryId" +from + "memories_audit" +where + "userId" = $1 + and "deletedAt" < now() - interval '1 millisecond' +order by + "id" asc + +-- SyncRepository.getMemoryAssetUpserts +select + "memoriesId" as "memoryId", + "assetsId" as "assetId", + "updateId" +from + "memories_assets_assets" +where + "memoriesId" in ( + select + "id" + from + "memories" + where + "ownerId" = $1 + ) + and "updatedAt" < now() - interval '1 millisecond' +order by + "updateId" asc + +-- SyncRepository.getMemoryAssetDeletes +select + "id", + "memoryId", + "assetId" +from + "memory_assets_audit" +where + "memoryId" in ( + select + "id" + from + "memories" + where + "ownerId" = $1 + ) + and "deletedAt" < now() - interval '1 millisecond' +order by + "id" asc diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 0d44af4bb2..db616f78cc 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -13,8 +13,18 @@ type AuditTables = | 'assets_audit' | 'albums_audit' | 'album_users_audit' - | 'album_assets_audit'; -type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums' | 'albums_shared_users_users'; + | 'album_assets_audit' + | 'memories_audit' + | 'memory_assets_audit'; +type UpsertTables = + | 'users' + | 'partners' + | 'assets' + | 'exif' + | 'albums' + | 'albums_shared_users_users' + | 'memories' + | 'memories_assets_assets'; @Injectable() export class SyncRepository { @@ -438,6 +448,61 @@ export class SyncRepository { .stream(); } + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getMemoryUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('memories') + .select([ + 'id', + 'createdAt', + 'updatedAt', + 'deletedAt', + 'ownerId', + 'type', + 'data', + 'isSaved', + 'memoryAt', + 'seenAt', + 'showAt', + 'hideAt', + ]) + .select('updateId') + .where('ownerId', '=', userId) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getMemoryDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('memories_audit') + .select(['id', 'memoryId']) + .where('userId', '=', userId) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getMemoryAssetUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('memories_assets_assets') + .select(['memoriesId as memoryId', 'assetsId as assetId']) + .select('updateId') + .where('memoriesId', 'in', (eb) => eb.selectFrom('memories').select('id').where('ownerId', '=', userId)) + .$call((qb) => this.upsertTableFilters(qb, ack)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getMemoryAssetDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('memory_assets_audit') + .select(['id', 'memoryId', 'assetId']) + .where('memoryId', 'in', (eb) => eb.selectFrom('memories').select('id').where('ownerId', '=', userId)) + .$call((qb) => this.auditTableFilters(qb, ack)) + .stream(); + } + private auditTableFilters, D>(qb: SelectQueryBuilder, ack?: SyncAck) { const builder = qb as SelectQueryBuilder; return builder diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index fdef5867b9..279eb62a24 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -176,3 +176,31 @@ export const album_users_delete_audit = registerFunction({ END`, synchronize: false, }); + +export const memories_delete_audit = registerFunction({ + name: 'memories_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO memories_audit ("memoryId", "userId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END`, + synchronize: false, +}); + +export const memory_assets_delete_audit = registerFunction({ + name: 'memory_assets_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO memory_assets_audit ("memoryId", "assetId") + SELECT "memoriesId", "assetsId" FROM OLD + WHERE "memoriesId" IN (SELECT "id" FROM memories WHERE "id" IN (SELECT "memoriesId" FROM OLD)); + RETURN NULL; + END`, + synchronize: false, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index bab3acb232..6dac1ff51e 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -8,6 +8,8 @@ import { f_unaccent, immich_uuid_v7, ll_to_earth_public, + memories_delete_audit, + memory_assets_delete_audit, partners_delete_audit, updated_at, users_delete_audit, @@ -30,8 +32,10 @@ import { ExifTable } from 'src/schema/tables/exif.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; import { LibraryTable } from 'src/schema/tables/library.table'; +import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table'; +import { MemoryAssetTable } from 'src/schema/tables/memory-asset.table'; +import { MemoryAuditTable } from 'src/schema/tables/memory-audit.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; -import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; import { MoveTable } from 'src/schema/tables/move.table'; import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table'; import { NotificationTable } from 'src/schema/tables/notification.table'; @@ -75,8 +79,10 @@ export class ImmichDatabase { FaceSearchTable, GeodataPlacesTable, LibraryTable, - MemoryAssetTable, MemoryTable, + MemoryAuditTable, + MemoryAssetTable, + MemoryAssetAuditTable, MoveTable, NaturalEarthCountriesTable, NotificationTable, @@ -110,6 +116,8 @@ export class ImmichDatabase { albums_delete_audit, album_user_after_insert, album_users_delete_audit, + memories_delete_audit, + memory_assets_delete_audit, ]; enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; diff --git a/server/src/schema/migrations/1751035357937-MemorySyncChanges.ts b/server/src/schema/migrations/1751035357937-MemorySyncChanges.ts new file mode 100644 index 0000000000..8704c30ca3 --- /dev/null +++ b/server/src/schema/migrations/1751035357937-MemorySyncChanges.ts @@ -0,0 +1,80 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "memory_assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "memoryId" uuid NOT NULL, "assetId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db); + await sql`CREATE TABLE "memories_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "memoryId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db); + await sql`ALTER TABLE "memories_assets_assets" ADD "createdAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`ALTER TABLE "memories_assets_assets" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`ALTER TABLE "memories_assets_assets" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`ALTER TABLE "memory_assets_audit" ADD CONSTRAINT "PK_35ef16910228f980e0766dcc59b" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "memories_audit" ADD CONSTRAINT "PK_19de798c033a710dcfa5c72f81b" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "memory_assets_audit" ADD CONSTRAINT "FK_225a204afcb0bd6de015080fb03" FOREIGN KEY ("memoryId") REFERENCES "memories" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`CREATE INDEX "IDX_memory_assets_audit_memory_id" ON "memory_assets_audit" ("memoryId")`.execute(db); + await sql`CREATE INDEX "IDX_memory_assets_audit_asset_id" ON "memory_assets_audit" ("assetId")`.execute(db); + await sql`CREATE INDEX "IDX_memory_assets_audit_deleted_at" ON "memory_assets_audit" ("deletedAt")`.execute(db); + await sql`CREATE INDEX "IDX_memory_assets_update_id" ON "memories_assets_assets" ("updateId")`.execute(db); + await sql`CREATE INDEX "IDX_memories_audit_memory_id" ON "memories_audit" ("memoryId")`.execute(db); + await sql`CREATE INDEX "IDX_memories_audit_user_id" ON "memories_audit" ("userId")`.execute(db); + await sql`CREATE INDEX "IDX_memories_audit_deleted_at" ON "memories_audit" ("deletedAt")`.execute(db); + await sql`CREATE OR REPLACE FUNCTION memories_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO memories_audit ("memoryId", "userId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE FUNCTION memory_assets_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO memory_assets_audit ("memoryId", "assetId") + SELECT "memoriesId", "assetsId" FROM OLD + WHERE "memoriesId" IN (SELECT "id" FROM memories WHERE "id" IN (SELECT "memoriesId" FROM OLD)); + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "memories_delete_audit" + AFTER DELETE ON "memories" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION memories_delete_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "memory_assets_delete_audit" + AFTER DELETE ON "memories_assets_assets" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() <= 1) + EXECUTE FUNCTION memory_assets_delete_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "memory_assets_updated_at" + BEFORE UPDATE ON "memories_assets_assets" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "memories_delete_audit" ON "memories";`.execute(db); + await sql`DROP TRIGGER "memory_assets_delete_audit" ON "memories_assets_assets";`.execute(db); + await sql`DROP TRIGGER "memory_assets_updated_at" ON "memories_assets_assets";`.execute(db); + await sql`DROP INDEX "IDX_memory_assets_update_id";`.execute(db); + await sql`DROP INDEX "IDX_memory_assets_audit_memory_id";`.execute(db); + await sql`DROP INDEX "IDX_memory_assets_audit_asset_id";`.execute(db); + await sql`DROP INDEX "IDX_memory_assets_audit_deleted_at";`.execute(db); + await sql`DROP INDEX "IDX_memories_audit_memory_id";`.execute(db); + await sql`DROP INDEX "IDX_memories_audit_user_id";`.execute(db); + await sql`DROP INDEX "IDX_memories_audit_deleted_at";`.execute(db); + await sql`ALTER TABLE "memory_assets_audit" DROP CONSTRAINT "FK_225a204afcb0bd6de015080fb03";`.execute(db); + await sql`ALTER TABLE "memory_assets_audit" DROP CONSTRAINT "PK_35ef16910228f980e0766dcc59b";`.execute(db); + await sql`ALTER TABLE "memories_audit" DROP CONSTRAINT "PK_19de798c033a710dcfa5c72f81b";`.execute(db); + await sql`ALTER TABLE "memories_assets_assets" DROP COLUMN "createdAt";`.execute(db); + await sql`ALTER TABLE "memories_assets_assets" DROP COLUMN "updatedAt";`.execute(db); + await sql`ALTER TABLE "memories_assets_assets" DROP COLUMN "updateId";`.execute(db); + await sql`DROP TABLE "memory_assets_audit";`.execute(db); + await sql`DROP TABLE "memories_audit";`.execute(db); + await sql`DROP FUNCTION memories_delete_audit;`.execute(db); + await sql`DROP FUNCTION memory_assets_delete_audit;`.execute(db); +} diff --git a/server/src/schema/tables/index.ts b/server/src/schema/tables/index.ts index 470f500bc2..67b36ab351 100644 --- a/server/src/schema/tables/index.ts +++ b/server/src/schema/tables/index.ts @@ -13,8 +13,8 @@ import 'src/schema/tables/exif.table'; import 'src/schema/tables/face-search.table'; import 'src/schema/tables/geodata-places.table'; import 'src/schema/tables/library.table'; +import 'src/schema/tables/memory-asset.table'; import 'src/schema/tables/memory.table'; -import 'src/schema/tables/memory_asset.table'; import 'src/schema/tables/move.table'; import 'src/schema/tables/natural-earth-countries.table'; import 'src/schema/tables/partner-audit.table'; diff --git a/server/src/schema/tables/memory-asset-audit.table.ts b/server/src/schema/tables/memory-asset-audit.table.ts new file mode 100644 index 0000000000..ecb72f6270 --- /dev/null +++ b/server/src/schema/tables/memory-asset-audit.table.ts @@ -0,0 +1,23 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { MemoryTable } from 'src/schema/tables/memory.table'; +import { Column, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools'; + +@Table('memory_assets_audit') +export class MemoryAssetAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: string; + + @ForeignKeyColumn(() => MemoryTable, { + type: 'uuid', + indexName: 'IDX_memory_assets_audit_memory_id', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + memoryId!: string; + + @Column({ type: 'uuid', indexName: 'IDX_memory_assets_audit_asset_id' }) + assetId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_memory_assets_audit_deleted_at' }) + deletedAt!: Date; +} diff --git a/server/src/schema/tables/memory-asset.table.ts b/server/src/schema/tables/memory-asset.table.ts new file mode 100644 index 0000000000..9512ed61da --- /dev/null +++ b/server/src/schema/tables/memory-asset.table.ts @@ -0,0 +1,36 @@ +import { ColumnType } from 'kysely'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { memory_assets_delete_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { MemoryTable } from 'src/schema/tables/memory.table'; +import { AfterDeleteTrigger, CreateDateColumn, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools'; + +type Timestamp = ColumnType; +type Generated = + T extends ColumnType ? ColumnType : ColumnType; + +@Table('memories_assets_assets') +@UpdatedAtTrigger('memory_assets_updated_at') +@AfterDeleteTrigger({ + name: 'memory_assets_delete_audit', + scope: 'statement', + function: memory_assets_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() <= 1', +}) +export class MemoryAssetTable { + @ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + memoriesId!: string; + + @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + assetsId!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ indexName: 'IDX_memory_assets_update_id' }) + updateId!: Generated; +} diff --git a/server/src/schema/tables/memory-audit.table.ts b/server/src/schema/tables/memory-audit.table.ts new file mode 100644 index 0000000000..efc6b66fd1 --- /dev/null +++ b/server/src/schema/tables/memory-audit.table.ts @@ -0,0 +1,22 @@ +import { ColumnType } from 'kysely'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Table } from 'src/sql-tools'; + +type Timestamp = ColumnType; +type Generated = + T extends ColumnType ? ColumnType : ColumnType; + +@Table('memories_audit') +export class MemoryAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', indexName: 'IDX_memories_audit_memory_id' }) + memoryId!: string; + + @Column({ type: 'uuid', indexName: 'IDX_memories_audit_user_id' }) + userId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_memories_audit_deleted_at' }) + deletedAt!: Timestamp; +} diff --git a/server/src/schema/tables/memory.table.ts b/server/src/schema/tables/memory.table.ts index 32dafe3384..d715820a9c 100644 --- a/server/src/schema/tables/memory.table.ts +++ b/server/src/schema/tables/memory.table.ts @@ -1,7 +1,9 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { MemoryType } from 'src/enum'; +import { memories_delete_audit } from 'src/schema/functions'; import { UserTable } from 'src/schema/tables/user.table'; import { + AfterDeleteTrigger, Column, CreateDateColumn, DeleteDateColumn, @@ -10,11 +12,17 @@ import { Table, UpdateDateColumn, } from 'src/sql-tools'; -import { MemoryData } from 'src/types'; @Table('memories') @UpdatedAtTrigger('memories_updated_at') -export class MemoryTable { +@AfterDeleteTrigger({ + name: 'memories_delete_audit', + scope: 'statement', + function: memories_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) +export class MemoryTable { @PrimaryGeneratedColumn() id!: string; @@ -31,10 +39,10 @@ export class MemoryTable { ownerId!: string; @Column() - type!: T; + type!: MemoryType; @Column({ type: 'jsonb' }) - data!: MemoryData[T]; + data!: object; /** unless set to true, will be automatically deleted in the future */ @Column({ type: 'boolean', default: false }) diff --git a/server/src/schema/tables/memory_asset.table.ts b/server/src/schema/tables/memory_asset.table.ts deleted file mode 100644 index 0e5ca29a08..0000000000 --- a/server/src/schema/tables/memory_asset.table.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AssetTable } from 'src/schema/tables/asset.table'; -import { MemoryTable } from 'src/schema/tables/memory.table'; -import { ForeignKeyColumn, Table } from 'src/sql-tools'; - -@Table('memories_assets_assets') -export class MemoryAssetTable { - @ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) - memoriesId!: string; - - @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) - assetsId!: string; -} diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index d55c58d9af..44929f2bbf 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { MemoryService } from 'src/services/memory.service'; +import { OnThisDayData } from 'src/types'; import { factory, newUuid, newUuids } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -87,7 +88,7 @@ describe(MemoryService.name, () => { await expect( sut.create(factory.auth({ user: { id: userId } }), { type: memory.type, - data: memory.data, + data: memory.data as OnThisDayData, memoryAt: memory.memoryAt, isSaved: memory.isSaved, assetIds: [assetId], @@ -117,7 +118,7 @@ describe(MemoryService.name, () => { await expect( sut.create(factory.auth({ user: { id: userId } }), { type: memory.type, - data: memory.data, + data: memory.data as OnThisDayData, assetIds: memory.assets.map((asset) => asset.id), memoryAt: memory.memoryAt, }), @@ -135,7 +136,11 @@ describe(MemoryService.name, () => { mocks.memory.create.mockResolvedValue(memory); await expect( - sut.create(factory.auth(), { type: memory.type, data: memory.data, memoryAt: memory.memoryAt }), + sut.create(factory.auth(), { + type: memory.type, + data: memory.data as OnThisDayData, + memoryAt: memory.memoryAt, + }), ).resolves.toBeDefined(); }); }); diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 484065bcc9..7e97f07370 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -65,6 +65,8 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.AssetExifsV1, SyncRequestType.AlbumAssetExifsV1, SyncRequestType.PartnerAssetExifsV1, + SyncRequestType.MemoriesV1, + SyncRequestType.MemoryToAssetsV1, ]; const throwSessionRequired = () => { @@ -120,108 +122,70 @@ export class SyncService extends BaseService { const checkpoints = await this.syncRepository.getCheckpoints(sessionId); const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)])); + const handlers: Record Promise> = { + [SyncRequestType.UsersV1]: () => this.syncUsersV1(response, checkpointMap), + [SyncRequestType.PartnersV1]: () => this.syncPartnersV1(response, checkpointMap, auth), + [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(response, checkpointMap, auth), + [SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(response, checkpointMap, auth), + [SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.PartnerAssetExifsV1]: () => + this.syncPartnerAssetExifsV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(response, checkpointMap, auth), + [SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.AlbumAssetExifsV1]: () => this.syncAlbumAssetExifsV1(response, checkpointMap, auth, sessionId), + [SyncRequestType.MemoriesV1]: () => this.syncMemoriesV1(response, checkpointMap, auth), + [SyncRequestType.MemoryToAssetsV1]: () => this.syncMemoryAssetsV1(response, checkpointMap, auth), + }; for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { - switch (type) { - case SyncRequestType.UsersV1: { - await this.syncUsersV1(response, checkpointMap); - break; - } - - case SyncRequestType.PartnersV1: { - await this.syncPartnersV1(response, checkpointMap, auth); - break; - } - - case SyncRequestType.AssetsV1: { - await this.syncAssetsV1(response, checkpointMap, auth); - break; - } - - case SyncRequestType.AssetExifsV1: { - await this.syncAssetExifsV1(response, checkpointMap, auth); - break; - } - - case SyncRequestType.PartnerAssetsV1: { - await this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId); - - break; - } - - case SyncRequestType.PartnerAssetExifsV1: { - await this.syncPartnerAssetExifsV1(response, checkpointMap, auth, sessionId); - break; - } - - case SyncRequestType.AlbumsV1: { - await this.syncAlbumsV1(response, checkpointMap, auth); - break; - } - - case SyncRequestType.AlbumUsersV1: { - await this.syncAlbumUsersV1(response, checkpointMap, auth, sessionId); - break; - } - - case SyncRequestType.AlbumAssetsV1: { - await this.syncAlbumAssetsV1(response, checkpointMap, auth, sessionId); - break; - } - - case SyncRequestType.AlbumToAssetsV1: { - await this.syncAlbumToAssetsV1(response, checkpointMap, auth, sessionId); - break; - } - - case SyncRequestType.AlbumAssetExifsV1: { - await this.syncAlbumAssetExifsV1(response, checkpointMap, auth, sessionId); - break; - } - - default: { - this.logger.warn(`Unsupported sync type: ${type}`); - break; - } - } + const handler = handlers[type]; + await handler(); } response.end(); } private async syncUsersV1(response: Writable, checkpointMap: CheckpointMap) { - const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]); + const deleteType = SyncEntityType.UserDeleteV1; + const deletes = this.syncRepository.getUserDeletes(checkpointMap[deleteType]); for await (const { id, ...data } of deletes) { - send(response, { type: SyncEntityType.UserDeleteV1, ids: [id], data }); + send(response, { type: deleteType, ids: [id], data }); } - const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]); + const upsertType = SyncEntityType.UserV1; + const upserts = this.syncRepository.getUserUpserts(checkpointMap[upsertType]); for await (const { updateId, ...data } of upserts) { - send(response, { type: SyncEntityType.UserV1, ids: [updateId], data }); + send(response, { type: upsertType, ids: [updateId], data }); } } private async syncPartnersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { - const deletes = this.syncRepository.getPartnerDeletes(auth.user.id, checkpointMap[SyncEntityType.PartnerDeleteV1]); + const deleteType = SyncEntityType.PartnerDeleteV1; + const deletes = this.syncRepository.getPartnerDeletes(auth.user.id, checkpointMap[deleteType]); for await (const { id, ...data } of deletes) { - send(response, { type: SyncEntityType.PartnerDeleteV1, ids: [id], data }); + send(response, { type: deleteType, ids: [id], data }); } - const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[SyncEntityType.PartnerV1]); + const upsertType = SyncEntityType.PartnerV1; + const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[upsertType]); for await (const { updateId, ...data } of upserts) { - send(response, { type: SyncEntityType.PartnerV1, ids: [updateId], data }); + send(response, { type: upsertType, ids: [updateId], data }); } } private async syncAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { - const deletes = this.syncRepository.getAssetDeletes(auth.user.id, checkpointMap[SyncEntityType.AssetDeleteV1]); + const deleteType = SyncEntityType.AssetDeleteV1; + const deletes = this.syncRepository.getAssetDeletes(auth.user.id, checkpointMap[deleteType]); for await (const { id, ...data } of deletes) { - send(response, { type: SyncEntityType.AssetDeleteV1, ids: [id], data }); + send(response, { type: deleteType, ids: [id], data }); } - const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetV1]); + const upsertType = SyncEntityType.AssetV1; + const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[upsertType]); for await (const { updateId, ...data } of upserts) { - send(response, { type: SyncEntityType.AssetV1, ids: [updateId], data: mapSyncAssetV1(data) }); + send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) }); } } @@ -231,21 +195,17 @@ export class SyncService extends BaseService { auth: AuthDto, sessionId: string, ) { - const backfillType = SyncEntityType.PartnerAssetBackfillV1; - const upsertType = SyncEntityType.PartnerAssetV1; const deleteType = SyncEntityType.PartnerAssetDeleteV1; - - const backfillCheckpoint = checkpointMap[backfillType]; - const upsertCheckpoint = checkpointMap[upsertType]; - const deletes = this.syncRepository.getPartnerAssetDeletes(auth.user.id, checkpointMap[deleteType]); - for await (const { id, ...data } of deletes) { send(response, { type: deleteType, ids: [id], data }); } + const backfillType = SyncEntityType.PartnerAssetBackfillV1; + const backfillCheckpoint = checkpointMap[backfillType]; const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, backfillCheckpoint?.updateId); - + const upsertType = SyncEntityType.PartnerAssetV1; + const upsertCheckpoint = checkpointMap[upsertType]; if (upsertCheckpoint) { const endId = upsertCheckpoint.updateId; @@ -283,9 +243,10 @@ export class SyncService extends BaseService { } private async syncAssetExifsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { - const upserts = this.syncRepository.getAssetExifsUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetExifV1]); + const upsertType = SyncEntityType.AssetExifV1; + const upserts = this.syncRepository.getAssetExifsUpserts(auth.user.id, checkpointMap[upsertType]); for await (const { updateId, ...data } of upserts) { - send(response, { type: SyncEntityType.AssetExifV1, ids: [updateId], data }); + send(response, { type: upsertType, ids: [updateId], data }); } } @@ -296,13 +257,11 @@ export class SyncService extends BaseService { sessionId: string, ) { const backfillType = SyncEntityType.PartnerAssetExifBackfillV1; - const upsertType = SyncEntityType.PartnerAssetExifV1; - const backfillCheckpoint = checkpointMap[backfillType]; - const upsertCheckpoint = checkpointMap[upsertType]; - const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, backfillCheckpoint?.updateId); + const upsertType = SyncEntityType.PartnerAssetExifV1; + const upsertCheckpoint = checkpointMap[upsertType]; if (upsertCheckpoint) { const endId = upsertCheckpoint.updateId; @@ -336,33 +295,31 @@ export class SyncService extends BaseService { } private async syncAlbumsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { - const deletes = this.syncRepository.getAlbumDeletes(auth.user.id, checkpointMap[SyncEntityType.AlbumDeleteV1]); - for await (const { id, ...data } of deletes) { - send(response, { type: SyncEntityType.AlbumDeleteV1, ids: [id], data }); - } - - const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumV1]); - for await (const { updateId, ...data } of upserts) { - send(response, { type: SyncEntityType.AlbumV1, ids: [updateId], data }); - } - } - - private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) { - const backfillType = SyncEntityType.AlbumUserBackfillV1; - const upsertType = SyncEntityType.AlbumUserV1; - const deleteType = SyncEntityType.AlbumUserDeleteV1; - - const backfillCheckpoint = checkpointMap[backfillType]; - const upsertCheckpoint = checkpointMap[upsertType]; - - const deletes = this.syncRepository.getAlbumUserDeletes(auth.user.id, checkpointMap[deleteType]); - + const deleteType = SyncEntityType.AlbumDeleteV1; + const deletes = this.syncRepository.getAlbumDeletes(auth.user.id, checkpointMap[deleteType]); for await (const { id, ...data } of deletes) { send(response, { type: deleteType, ids: [id], data }); } - const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId); + const upsertType = SyncEntityType.AlbumV1; + const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) { + const deleteType = SyncEntityType.AlbumUserDeleteV1; + const deletes = this.syncRepository.getAlbumUserDeletes(auth.user.id, checkpointMap[deleteType]); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const backfillType = SyncEntityType.AlbumUserBackfillV1; + const backfillCheckpoint = checkpointMap[backfillType]; + const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId); + const upsertType = SyncEntityType.AlbumUserV1; + const upsertCheckpoint = checkpointMap[upsertType]; if (upsertCheckpoint) { const endId = upsertCheckpoint.updateId; @@ -397,13 +354,10 @@ export class SyncService extends BaseService { private async syncAlbumAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) { const backfillType = SyncEntityType.AlbumAssetBackfillV1; - const upsertType = SyncEntityType.AlbumAssetV1; - const backfillCheckpoint = checkpointMap[backfillType]; - const upsertCheckpoint = checkpointMap[upsertType]; - const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId); - + const upsertType = SyncEntityType.AlbumAssetV1; + const upsertCheckpoint = checkpointMap[upsertType]; if (upsertCheckpoint) { const endId = upsertCheckpoint.updateId; @@ -443,13 +397,10 @@ export class SyncService extends BaseService { sessionId: string, ) { const backfillType = SyncEntityType.AlbumAssetExifBackfillV1; - const upsertType = SyncEntityType.AlbumAssetExifV1; - const backfillCheckpoint = checkpointMap[backfillType]; - const upsertCheckpoint = checkpointMap[upsertType]; - const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId); - + const upsertType = SyncEntityType.AlbumAssetExifV1; + const upsertCheckpoint = checkpointMap[upsertType]; if (upsertCheckpoint) { const endId = upsertCheckpoint.updateId; @@ -488,22 +439,17 @@ export class SyncService extends BaseService { auth: AuthDto, sessionId: string, ) { - const backfillType = SyncEntityType.AlbumToAssetBackfillV1; - const upsertType = SyncEntityType.AlbumToAssetV1; - - const backfillCheckpoint = checkpointMap[backfillType]; - const upsertCheckpoint = checkpointMap[upsertType]; - - const deletes = this.syncRepository.getAlbumToAssetDeletes( - auth.user.id, - checkpointMap[SyncEntityType.AlbumToAssetDeleteV1], - ); + const deleteType = SyncEntityType.AlbumToAssetDeleteV1; + const deletes = this.syncRepository.getAlbumToAssetDeletes(auth.user.id, checkpointMap[deleteType]); for await (const { id, ...data } of deletes) { - send(response, { type: SyncEntityType.AlbumToAssetDeleteV1, ids: [id], data }); + send(response, { type: deleteType, ids: [id], data }); } + const backfillType = SyncEntityType.AlbumToAssetBackfillV1; + const backfillCheckpoint = checkpointMap[backfillType]; const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId); - + const upsertType = SyncEntityType.AlbumToAssetV1; + const upsertCheckpoint = checkpointMap[upsertType]; if (upsertCheckpoint) { const endId = upsertCheckpoint.updateId; @@ -536,6 +482,34 @@ export class SyncService extends BaseService { } } + private async syncMemoriesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deleteType = SyncEntityType.MemoryDeleteV1; + const deletes = this.syncRepository.getMemoryDeletes(auth.user.id, checkpointMap[SyncEntityType.MemoryDeleteV1]); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.MemoryV1; + const upserts = this.syncRepository.getMemoryUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + + private async syncMemoryAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deleteType = SyncEntityType.MemoryToAssetDeleteV1; + const deletes = this.syncRepository.getMemoryAssetDeletes(auth.user.id, checkpointMap[deleteType]); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.MemoryToAssetV1; + const upserts = this.syncRepository.getMemoryAssetUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) { const { type, sessionId, createId } = item; await this.syncRepository.upsertCheckpoints([ diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 69708d0fce..8c113a13cf 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -4,9 +4,9 @@ import { DateTime } from 'luxon'; import { createHash, randomBytes } from 'node:crypto'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; -import { Albums, AssetJobStatus, Assets, DB, Exif, FaceSearch, Person, Sessions } from 'src/db'; +import { Albums, AssetJobStatus, Assets, DB, Exif, FaceSearch, Memories, Person, Sessions } from 'src/db'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AlbumUserRole, AssetType, AssetVisibility, SourceType, SyncRequestType } from 'src/enum'; +import { AlbumUserRole, AssetType, AssetVisibility, MemoryType, SourceType, SyncRequestType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -129,6 +129,17 @@ export class MediumTestContext { return { asset, result }; } + async newMemory(dto: Partial> = {}) { + const memory = mediumFactory.memoryInsert(dto); + const result = await this.get(MemoryRepository).create(memory, new Set()); + return { memory, result }; + } + + async newMemoryAsset(dto: { memoryId: string; assetId: string }) { + const result = await this.get(MemoryRepository).addAssetIds(dto.memoryId, [dto.assetId]); + return { memoryAsset: dto, result }; + } + async newExif(dto: Insertable) { const result = await this.get(AssetRepository).upsertExif(dto); return { result }; @@ -452,6 +463,28 @@ const userInsert = (user: Partial> = {}) => { return { ...defaults, ...user, id }; }; +const memoryInsert = (memory: Partial> = {}) => { + const id = memory.id || newUuid(); + const date = newDate(); + + const defaults: Insertable = { + id, + createdAt: date, + updatedAt: date, + deletedAt: null, + type: MemoryType.ON_THIS_DAY, + data: { year: 2025 }, + showAt: null, + hideAt: null, + seenAt: null, + isSaved: false, + memoryAt: date, + ownerId: memory.ownerId || newUuid(), + }; + + return { ...defaults, ...memory, id }; +}; + class CustomWritable extends Writable { private data = ''; @@ -483,4 +516,5 @@ export const mediumFactory = { sessionInsert, syncStream, userInsert, + memoryInsert, }; diff --git a/server/test/medium/specs/sync/sync-memory-asset.spec.ts b/server/test/medium/specs/sync/sync-memory-asset.spec.ts new file mode 100644 index 0000000000..a6cfadea6d --- /dev/null +++ b/server/test/medium/specs/sync/sync-memory-asset.spec.ts @@ -0,0 +1,84 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { MemoryRepository } from 'src/repositories/memory.repository'; +import { SyncTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.MemoryToAssetV1, () => { + it('should detect and sync a memory to asset relation', async () => { + const { auth, user, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { memory } = await ctx.newMemory({ ownerId: user.id }); + await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + memoryId: memory.id, + assetId: asset.id, + }, + type: 'MemoryToAssetV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).resolves.toEqual([]); + }); + + it('should detect and sync a deleted memory to asset relation', async () => { + const { auth, user, ctx } = await setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { memory } = await ctx.newMemory({ ownerId: user.id }); + await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); + await memoryRepo.removeAssetIds(memory.id, [asset.id]); + + const response = await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + memoryId: memory.id, + }, + type: 'MemoryToAssetDeleteV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).resolves.toEqual([]); + }); + + it('should not sync a memory to asset relation or delete for an unrelated user', async () => { + const { auth, ctx } = await setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { auth: auth2, user: user2 } = await ctx.newSyncAuthUser(); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { memory } = await ctx.newMemory({ ownerId: user2.id }); + await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); + + expect(await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(0); + expect(await ctx.syncStream(auth2, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(1); + + await memoryRepo.removeAssetIds(memory.id, [asset.id]); + expect(await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(0); + expect(await ctx.syncStream(auth2, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(1); + }); +}); diff --git a/server/test/medium/specs/sync/sync-memory.spec.ts b/server/test/medium/specs/sync/sync-memory.spec.ts new file mode 100644 index 0000000000..df41671fde --- /dev/null +++ b/server/test/medium/specs/sync/sync-memory.spec.ts @@ -0,0 +1,115 @@ +import { Kysely } from 'kysely'; +import { DB } from 'src/db'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { MemoryRepository } from 'src/repositories/memory.repository'; +import { SyncTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.MemoryV1, () => { + it('should detect and sync the first memory with the right properties', async () => { + const { auth, user: user1, ctx } = await setup(); + const { memory } = await ctx.newMemory({ ownerId: user1.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + id: memory.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + deletedAt: memory.deletedAt, + type: memory.type, + data: memory.data, + hideAt: memory.hideAt, + showAt: memory.showAt, + seenAt: memory.seenAt, + memoryAt: expect.any(String), + isSaved: memory.isSaved, + ownerId: memory.ownerId, + }, + type: 'MemoryV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]); + }); + + it('should detect and sync a deleted memory', async () => { + const { auth, user, ctx } = await setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { memory } = await ctx.newMemory({ ownerId: user.id }); + await memoryRepo.delete(memory.id); + + const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + memoryId: memory.id, + }, + type: 'MemoryDeleteV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]); + }); + + it('should sync a memory and then an update to that same memory', async () => { + const { auth, user, ctx } = await setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { memory } = await ctx.newMemory({ ownerId: user.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ id: memory.id }), + type: 'MemoryV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await memoryRepo.update(memory.id, { seenAt: new Date() }); + const newResponse = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]); + expect(newResponse).toHaveLength(1); + expect(newResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ id: memory.id }), + type: 'MemoryV1', + }, + ]); + + await ctx.syncAckAll(auth, newResponse); + await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]); + }); + + it('should not sync a memory or a memory delete for an unrelated user', async () => { + const { auth, ctx } = await setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { user: user2 } = await ctx.newUser(); + const { memory } = await ctx.newMemory({ ownerId: user2.id }); + + await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]); + await memoryRepo.delete(memory.id); + await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]); + }); +});