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 | Bin 37881 -> 38085 bytes mobile/openapi/lib/api.dart | Bin 13419 -> 13581 bytes mobile/openapi/lib/api_client.dart | Bin 33783 -> 34131 bytes .../openapi/lib/model/sync_entity_type.dart | Bin 6521 -> 7167 bytes .../model/sync_memory_asset_delete_v1.dart | Bin 0 -> 3224 bytes .../lib/model/sync_memory_asset_v1.dart | Bin 0 -> 3110 bytes .../lib/model/sync_memory_delete_v1.dart | Bin 0 -> 2894 bytes mobile/openapi/lib/model/sync_memory_v1.dart | Bin 0 -> 5849 bytes .../openapi/lib/model/sync_request_type.dart | Bin 4112 -> 4424 bytes 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, 926 insertions(+), 197 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 b09f36548aec2992b0effeb4ee463129319b8888..d9607ef2ec3c61a2efb023bf6200518188f4e54b 100644 GIT binary patch delta 150 zcmeyloayLFrVV!**?d!T^NT7c>o-Win0^gXfe=y0;^NejFvD1ll>B6U{ou;HWE63| n+!ReN1zm+`gp5mSPHIUi7A=zxHi$|?bYqbXGu(W>QA-E_-jp~D delta 18 acmX@QlIiDirVV!*C*Nyg-@Lv_Ob7s9s0k(j diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index df61b8ab004b769292e61e9940eaa007e52133f1..67f75fba38b7a08df0af53a97f2d615131d547c8 100644 GIT binary patch delta 62 zcmaEz(VMj)T9qv~H8;Pgax$ZeCJ%%epIBU+S~8hWMFu7pr=kpF0!24Rs|xS~0Nv6R AsQ>@~ delta 12 TcmeCpdY!Q$T6ObZRRMkgDEI|$ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 18b2c9202b4429e1cd9ef39638f44c61690debb4..d0f7837bc9b368588cadb852928e403a1619f4a2 100644 GIT binary patch delta 102 zcmey~&UCqpX~XMSHs93T{G!UqucK69%>6NjFs6UBJdBwbs|{m*h*E$tWukRp%=OVy OFlIr#+UD1>JZb<_wJ0M1 delta 14 VcmccI#q_lTVj%fsxl2HOR)FFCO|RiQ{7;?~Iv1r=dFkP(%H>GTs<;(+K3$)D^iEP>`T PBuxj!6*ikoI57eMYF>JE delta 71 zcmV-N0J#7EH~BKKtOApu0|%3A1ACLK0>Gp00|k=?3cHi?3M7-k3OJL03k9xLIDzJ8s-21 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 0000000000000000000000000000000000000000..a9af77e9294613d8055c657016bc681969d38b92 GIT binary patch literal 3224 zcmbVOZExE)5dQ98aVd&g!BlzOry`xb21_!eYhs{v3k*geFcNLElSPfBY8Yw$`|ggS zEH_pY^g~mNy!Yq1ua5_6bgNw`e=P{g3;d(lQtLfS0#XmX|OB6QxU4jt|lWI9Own;N|vHh3I2OC7-WU87Cvsv>`Jb+*`8V}-5KWR@SQf^@0BYp#lgUw`udOboB*#ro+0Jl;$!5E~w%;N)BYYxlb-5+DbXxSN6E}>EC z9tB}Od#k@nW3Xf$=z+-hPo8*2Ar&Ifn5Be0?)fL=;gRZoYlBS3=KZalYMew-0dEmRLuG9WU#MK%0pgC_mjh_by+oZUb!99`eXJ`+i%)!qB3(8seDvDZKSQx!-OwRcTJ7s|aL+YqG*5xLKvM!LJM8}P? z+&~?$DV|;1bm30{B^Z7B9~`Q zM;vDi&w7A=hOJ0_jCpubY7opMSOVCClC33~uUf$G=y7Anx^{i73Fz&rcR*1 z+Y4mG9t=L@JDyWZqc`HVz^;ddD6EX{LsflEjkI;)xIub+%x`w+kYxggg2GY6EKiiZ z*6w^=xot3wfZ=3}8V`Ddvz$eD_W@GI6212%m=N1JH%z0c7!`iUl%p}Ty=$2s1WGCq zdG7ht(e(AEz9c())o-+%rtG3nxDyD!*b4V^15I}b^&~JMS{(!^MC^-%Qj7t=MsZ&` zCShn%{s9Y&;)hVM+}K0a*EBexn*HPi+EpUO*1)BOmJ|OJ?7@nsW*dM_=u1f(lA_xB z#GVW=+e=uy1J@%!W7+0j@n5;6!Oi-fuRDAL&UB^^hER)kKQf9mJHQrVSZ`A^XGr28Z(?{_W25gbqkio1XL$8Q$~=XB z%`ol^jYrvq>Fp!m15ZNmwT0}EEN=UH*Wq!?yaVF_J$48)g4s&8+-NsYq@l&_K_K=u z(&knIKDQR#Ql|)b_i=hn`)orAeuLZf^>5N`H0r{vY^0ez}fsf|pR+N4WW$TO(R+R8kYh1|%}#o=3RDs9|R4^eEz&ZbrE zQX~Jal|lDXZ18U-4F2z=u^8Og~#g)}l;b z|CnZLVeDXl=^W@K=t`ENQVIThGZxRURMN+Hetm@8J~d!E&*4b=v=k7?)v>!FiKo{5;Us9%uYKq*)9({I#fl_R(UQNU+hf-&^0?;qaq0e~&$X4oVhF3`Fwo^Q-TA$z;9;tdVUn1j;7 z0}1QD$s0tOgYR<-%31g#idtG&8ogX|trvWTIq3)CZtZ>uafN{8Ue z$R&0wH@c}aRIE)&%PID~Q(1~5Vy$x`+%NQ->=!PAQEpK8#RbUgYGp(&FPx6J!Wf?Q zXnuyxMxBbadn0NAtUkm5I3SMWB$=?TM`7gK*0((kkAA@sry`4n-ZR*U>JtGvTi)&} z*#w~u-GB`c)Q-gd1!;d-#!{Oe$Iqd@%(ujjtYQ@oD{a(=tznzW~C~*A}nLhyC z7kkgsXJzzO+!ff-uoQ)r@k2n;<7lL<3&$q=h83JBd!yZSQ@LFT ziuB;5i<;Pai&L3R_xpiBW)AcA4waCS`8-&9gAc*HSH#JfmE4U3Di{ zsoN;d0oIL|({!#VfOQ7p7f0bPZHVZOmR{)-lFv~(LaiPcv|_~hMv40nOv3n~GY8}` z^S=Vmc4JS>_Zk9cP4kvGgZA1_QT+J8pofGX=LaO>s@Vk^6Z%rphT{;P{zy-tlOrW; z*dgZ;S`n(bcl=B4X!x?f*X5qg!0G|F=-Rdso*O@D6f#WO-@{ww*0@AXL@$yapD(=~ zx;0}KFA=n-w;6>)IUP|2Dd|Y7vNmY%s6JwNTDxM_u5!MY>;LHIiPCtA%$l*<83m83 z2oHA1bB~OKXllXhh$(Knb>FvftFR+YkGVR+7{TpYwi;+(h$uIUH@!fnYm~>G1iT%ik|=FF(GUUtYk~^@qzCE@p5$o5RiQ;`-{# z5t@8X$+Q* z`}bNGl)Mp68<GxKb=*@GoRHZ23D=xtVde#q*=bSIF#loC~ z2q$-Fen6u)G%OXiriDjh^*>4oM45x{CJV}0_$G>4T38yrX}m3X301d5gA)?w0&BgC zp{xrO2&rMKEQ_&2NM+;_dxG1^)EP?1rX;QuyUVF8#YSA~oCxv@1490lvtR%lKz()r z&AM6{k;@CGBTg}f7k$EBAlK9mn7y|mRbu|Z3BUm<>?p~Ebv=3_uNvQ5)T09_M;wbx z0(WPy5!Gh`b+(-ED%rT5Mx=rmIwYSSQFGEylky|KHY5|+Ey)dJ9~7n88zoaHaN`OY zJiyKeeBicT8NC(v1$GiFMPX(981V3D7-{Rmu}J&w7k7I|$tfX9slf3uYcWywR=dkh z<#xdnYMkpWjxaXe?*|^301VtOD^h}6NTnnq&prR0 zlOAAd9P)DjcXP^VDl7_cok4hFFWhhq7v1*n=RPGB3^Edu_0XUdgU+Kl9spq)h6Gg| zz{lACiapy+JU35C8wW>o`<_92QKooT;gw8RuOFocJmRX^1tt@EQ__Zd2vJ|ta~Ne; z30rpf`2n*C*1{kBOYUey@_Db#J)wcv1A6gj+e}zEzS3M{oV35?w#uz>i8_fc@jj(5 zy&;}!h9(YsJV4(wF^oPX_xx8hWNYo+Gtp%5^5f z50!+K9a7!rBO$6<_&S1$+os*OZ``VEp!YFYM;v3AuVt%)_NxjdXYo!JNOp}9xtD-9 rp2e`#5CYzRn%&apS)&2I2HQ`vKj>`x0Wf`FK=!vif1{m`aJu{hTCA#~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2ae2b01fd7403429334cb5513f7b7e8e9e53735d GIT binary patch literal 5849 zcmbVQZBH9H5dO}u7^OWOXm!vNy#i99l}-_gOx7XpZFY0E(IaC|^WyO{ue#lDIyr_9r=LzTIKF`Miz_(0I6nRG z&n~hN=et5^oBuw1^?HXM)ooR4IiA*fJXNJUfMvBbvKY%!&Shn@@T}>H8(uQLe<= zj2BBg*7Col(kNbuIsRV=jlWiEgUYq(7N69Wv7)kLRp5bYs-$Ld(|K5_Drv@LUELy^ zor!Av`sH{s6WZ+T;Bgk}Dbx#DiG>>C-eEg!BW2YsWM=iMn_|}s*=5~wfgl8Y?)T6w0LQpT1LLQ11;}JL%Ha8gn z-gH5Ah4%)!e2Wp{wR!tp{atE}e#OhPX&9(l*jhuL5B#-jJCk}3@rNcy&1B$Q= zJmtp^x59h1!V;c8=g?!eCLU3BgKwLM5GFGJwxlD_Ob)2Gr5)(R;8!gTsvrvfZ|}Mo zWs+aE=)o(*5Z5gZ?;~Oe%NE^vi^zV}rn1|Z>Q*f(xsPc4+oD7_5;;C>`BYUFZl#5p zFtb1F0+k)IOJbR&>RQQ(G)Cw(RY&yjOd4gFZ4mQd7&Pp`Z;nJh>e!90LA$DDS;7&# zgo7qgcDH33za;{0vx8d|+ZudAZ4-jawkh6M+l0nni8XGs!x~q2$O$PoyRw6u{J4E$ zbYDZ-a-^>!wswmF=VL!@0;A}wycd@lr&8V{XW=?H2RFhp^U0F;9O4wL03WSS#4c7-&21f7SBO4Myy6b)BJa~yR^8f6-yi@k)yZEUcGcx?E> z&SP-KlYIO(8vMqFcYS$y2tP+|l!U_Zi})q#Ht~xd^(E**i#Y8Pi+pkVRf`&Rn-)bV zDYu&g>1o4VG1nWexb>LBG*8GJ^`!Nl=C&tK&LFA++>3(&Wet26U!?=jCc!& z?I|+!X%Zh{DZ0*Ff#+Ffr3}yDNf%vcImPjx-yHOEf)kV((`+@y7YQ7h#`w-Ls|)I? zZlg6Q9r5{G*?gsQc7?*RT-<6=$YWa%*tiUyrIr5~4zH9l6cJ3PK@4pU;sC8W$t=T( zFm|hA4%`?_$~?^JIRie0h_R3!z+5bj{nR|5LYzTbXX&ZGx9jF_lB9acLSG5^i|G{x z$6GgO9!7aCu`Q`k9lOtKN}oHgInLFeZYf`zHbqpiWuNN=jeS_LQtNzw+!CXKIuY`f zER_sJs@uRo?QfyzOViegn}&v(mY(TKO81Gr+-ySPD$D~St{!ZPP|_d38NcR{kB5|D z9N`os*o%pwf1Mf@D?s-_k-Q0qkABzZeAtH4#MQxP+$Tj5pS*M*LWx@+f?*-`Tom8& z>(H&PSK_9`xf!Mc+irFrlFFr&mS$O69zIgVqw6LkNgX959gpuGT)2kHoY(ebzOd_{ z50ez%x&~fty~3A$W^L#pja`c8tqRh>iw1i@DBYTpu+<)j?93mEqHu*Hk#ulUeI>>g zxL(TEbfrOn=m5fswWP?Wqg>koSUo!N54U!3AXhrywN!*5k8T>E#`AuPH1p-o6A#rv z>?!_B)B(hgMq~VLgP*79Gn9KlYIzNJ^*Z=%NKZwLQd5W5L9v%`iZFj~(22aaH)R9I>$1%SWD^CoAr-YPmpGlrS8ZmgMwqsND zhvbq7SrRx|OvNTCvlvYs8I8vzL9h>3Ga3CIzbayA8GLCH=-MTQ!i@wRdJG;*Aur(L z<;6LDnDo__C!zRB@^6xjuV{yN3`O!j<~nwt8-G}bp5ONyp$?7@EDx8LyCbWO(? cdZ6bHEzS{LXA7d-lRRJ2Zjcqj-s = 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 d46779182..77f0578c3 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 bbe8f001d..4a89baa6b 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 46b72ffca..0abe98531 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 0d44af4bb..db616f78c 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 fdef5867b..279eb62a2 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 bab3acb23..6dac1ff51 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 000000000..8704c30ca --- /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 470f500bc..67b36ab35 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 000000000..ecb72f627 --- /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 000000000..9512ed61d --- /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 000000000..efc6b66fd --- /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 32dafe338..d715820a9 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 0e5ca29a0..000000000 --- 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 d55c58d9a..44929f2bb 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 484065bcc..7e97f0737 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 69708d0fc..8c113a13c 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 000000000..a6cfadea6 --- /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 000000000..df41671fd --- /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([]); + }); +});